import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; import { ref, computed, watch, markRaw } from "vue"; import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, DatabaseType, DatabaseConnectionInfo, DatabaseStorageInfo, SqlServerCompletionContext, CatalogInfo, ForeignKeyInfo, ObjectInfo, ObjectStatistics, SchemaInfo, SidebarLayout, TableNameFilter, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta, } from "@/types/database"; import { inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeOrder, normalizePinnedTreeNodeOrder, orderItemsByPinnedTreeNodeOrder, pinnedTreeNodeIdentityMatches, removePinnedTreeNodesFromOrder, reorderPinnedTreeNodeOrder, replacePinnedTreeNodeInOrder, syncPinnedTreeNodeStateInPlace, treeNodePinIdentity, treeNodePinKey, type PinnedTreeNodeIdentity, type PinnedTreeNodeIdentityCanonicalizer, } from "@/lib/app/pinnedItems"; import { reconcileLayout, buildTreeNodesFromLayout, emptyLayout, appendConnectionToLayout, removeConnectionFromSidebarLayout, findConnectionLocation, createGroup as createGroupOp, renameGroup as renameGroupOp, deleteGroup as deleteGroupOp, toggleGroupCollapsed as toggleGroupCollapsedOp, collapseAllGroups as collapseAllGroupsOp, moveConnectionToGroup as moveConnectionToGroupOp, remapSidebarLayoutConnectionIds, reorderEntry as reorderEntryOp, buildConnectionGroupPathMap, type DropPosition, } from "@/lib/sidebar/sidebarLayout"; import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation"; import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionShouldLoadIdentifierQuote, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbIdentifierQuoteOverride } from "@/lib/database/jdbcDialect"; import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree"; import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree"; import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse"; import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget"; import { simpleModeEmptyShellNeedsConfirmedLoad, treeNodeLoadedChildrenContentPresent } from "@/lib/sidebar/treeLoadedChildrenMarker"; import { shouldMarkDisconnected } from "@/lib/connection/connectionHealth"; import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout"; import { loadTimeoutInheritanceBackup, saveTimeoutInheritanceBackup } from "@/lib/connection/timeoutInheritanceBackup"; import { migrateSqlServerLegacyCompatibilityConfig, requiresSqlServerLegacyCompatibilityComponent, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility"; import { deleteTabResultSnapshotsForOwner } from "@/lib/tabs/tabResultCache"; import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForConnection, filterSchemaNamesForConnection, filterVisibleDatabaseNames, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases"; import { buildObjectGroupPlaceholderNodes, buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, appendTableTreeLoadMoreNode, expandCachedObjectBrowserNodes, filterSimpleSidebarSupplementalObjects, mergeTableInfosIntoObjects, mergeTableTreePageChildren, objectGroupRefreshParentId, objectTypesForGroupNode, sortDatabaseObjectsByName, tablePartitionGroups, withoutTableTreeLoadMoreNodes, type TableTreeLoadMoreParent, type DatabaseObjectTreeKind, } from "@/lib/table/tableTree"; import { hasTreeNodeDatabaseContext, normalizeCataloglessDatabaseNodes, treeNodeSchemaCachePrefix } from "@/lib/sidebar/treeNodeContext"; import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/schemaTreeCache"; import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering"; import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin"; import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { useSettingsStore } from "@/stores/settingsStore"; import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers"; import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion"; import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities"; import { mongoCollectionKindFromNode, toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation"; import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex"; import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias"; import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint"; import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints"; import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases"; import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility"; import { buildPackageMemberNodes, markPackageNodesExpandable } from "@/lib/sidebar/packageMembers"; import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo"; import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator"; import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope"; import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache"; import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache"; import { invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache"; import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache"; import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle"; import i18n from "@/i18n"; import type { MqAdminConfig } from "@/types/mq"; import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults"; import { applySidebarDatabaseStorage, applySidebarTableStorage, sidebarDatabaseNames, supportsSidebarDatabaseStorage, supportsSidebarTableStorage, type SidebarTableStorageScope } from "@/lib/sidebar/sidebarDatabaseStorage"; import { connectionHasConfiguredSidebarVisibleFilter, nacosVisibleNamespaceSummary, sidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary"; import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; import { isTdengineStableTableType } from "@/lib/table/tableEditing"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection"; const SIDEBAR_TABLE_NAME_FILTERS_STORAGE_KEY = "dbx-sidebar-table-name-filters"; const CONNECTION_HEALTH_CHECK_TTL_MS = 2000; const CONNECTION_HEALTH_CHECK_TIMEOUT_MS = 5000; const METADATA_LOAD_MIN_TIMEOUT_MS = 15_000; const METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS = 60_000; const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000; const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30; const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000; const METADATA_LIST_PAGE_CACHE_MAX_ENTRIES = 160; const SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS = 30_000; export const COMPLETION_METADATA_CONCURRENCY = 2; const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy"; const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)"; const XUGU_TABLE_CHILD_METADATA_AGENT_VERSION = "0.1.23"; const SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE = "Connection attempt was superseded by a newer attempt"; function normalizeTableNameFilter(filter: Partial | undefined | null): TableNameFilter { const normalizePatterns = (patterns: unknown): string[] => (Array.isArray(patterns) ? patterns.map((pattern) => (typeof pattern === "string" ? pattern.trim() : "")).filter(Boolean) : []); return { includePatterns: normalizePatterns(filter?.includePatterns), excludePatterns: normalizePatterns(filter?.excludePatterns), }; } function tableNameFilterIsEmpty(filter: TableNameFilter | undefined | null): boolean { return !filter || (filter.includePatterns.length === 0 && filter.excludePatterns.length === 0); } function loadSidebarTableNameFilters(): Record { if (typeof localStorage === "undefined") return {}; try { const parsed = JSON.parse(localStorage.getItem(SIDEBAR_TABLE_NAME_FILTERS_STORAGE_KEY) || "{}"); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; const result: Record = {}; for (const [key, value] of Object.entries(parsed)) { const filter = normalizeTableNameFilter(value as Partial); if (!tableNameFilterIsEmpty(filter)) result[key] = filter; } return result; } catch { return {}; } } function saveSidebarTableNameFilters(filters: Record) { if (typeof localStorage === "undefined") return; localStorage.setItem(SIDEBAR_TABLE_NAME_FILTERS_STORAGE_KEY, JSON.stringify(filters)); } function sidebarObjectGroupPageSize(): number { const settingsStore = useSettingsStore(); const size = settingsStore.desktopSettings.sidebar_table_page_size; return typeof size === "number" && size > 0 ? size : 500; } function isFlatMqConnection(config: ConnectionConfig | undefined): boolean { if (!config || config.db_type !== "mq") return false; if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq") return true; const kind = (config.external_config as Partial | undefined)?.systemKind; return kind === "kafka" || kind === "rocketmq" || kind === "rabbitmq"; } type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip"; interface LocateTableTarget { connectionId: string; database: string; schema?: string; tableName: string; } function nodeIdPart(value: string): string { return encodeURIComponent(value); } function sqlServerLinkedRootId(connectionId: string): string { return `${connectionId}:__linked_servers`; } function sqlServerLinkedServerId(connectionId: string, server: string): string { return `${sqlServerLinkedRootId(connectionId)}:${nodeIdPart(server)}`; } function sqlServerLinkedCatalogId(connectionId: string, server: string, catalog: string): string { return `${sqlServerLinkedServerId(connectionId, server)}:${nodeIdPart(catalog)}`; } function dorisCatalogId(connectionId: string, catalog: string): string { return `${connectionId}:doris-catalog:${nodeIdPart(catalog)}`; } function dorisCatalogDatabaseId(connectionId: string, catalog: string, database: string): string { return `${dorisCatalogId(connectionId, catalog)}:${nodeIdPart(database)}`; } function sqlServerLinkedRuntimeDatabase(config?: ConnectionConfig): string { return config?.database?.trim() || "master"; } function sqlServerLinkedRootNode(connectionId: string, database: string): TreeNode { return { id: sqlServerLinkedRootId(connectionId), label: "tree.linkedServers", type: "linked-server-root", connectionId, database, isExpanded: false, children: [], }; } function ensureSqlServerLinkedRootNode(connectionId: string, children: TreeNode[], config?: ConnectionConfig): TreeNode[] { if (config?.db_type !== "sqlserver") return children; if (children.some((child) => child.type === "linked-server-root" || child.id === sqlServerLinkedRootId(connectionId))) { return children; } return [...children, sqlServerLinkedRootNode(connectionId, sqlServerLinkedRuntimeDatabase(config))]; } // Temporary storage for DataGrip import payload (used to read Keychain passwords after import) let pendingDataGripPayload: { format: "datagrip-import"; dataSources: string; dataSourcesLocal?: string; dbForestConfig?: string } | null = null; interface TreeClipboardTableEntry { connectionId: string; database: string; schema?: string; tableName: string; tableComment?: string | null; } interface TreeClipboardConnectionEntry { config: ConnectionConfig; sourceGroupId: string | null; } export type TreeClipboard = | { kind: "table-copy"; tables: TreeClipboardTableEntry[]; } | { kind: "connection-copy"; connections: TreeClipboardConnectionEntry[]; }; interface LoadTreeOptions { force?: boolean; connectedOnly?: boolean; expectedSidebarSearchQuery?: string; searchFilter?: string; sidebarTableSearchParentId?: string; expectedSidebarTableSearchQuery?: string; tableNameFilterScopeKey?: string; expectedTableNameFilterRevision?: number; } interface PersistedTreeChildrenLoadResult { hit: boolean; isStale: boolean; } type MetadataListPageResult = TableInfo[] | ObjectInfo[]; type BeforeConnectHandler = (config: ConnectionConfig) => Promise; export const CONNECTION_ATTEMPT_CANCELLED_MESSAGE = "Connection attempt was cancelled"; function metadataDriverProfile(config?: ConnectionConfig): string | undefined { return config?.driver_profile || config?.db_type; } export const useConnectionStore = defineStore("connection", () => { const settingsStore = useSettingsStore(); const tunnelProfileStore = useTunnelProfileStore(); const connections = ref([]); const isDesktop = isTauriRuntime(); const activeConnectionId = ref(localStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY)); const selectedTreeNodeId = ref(null); const selectedTreeNodeIds = ref([]); // O(1) membership set — rebuilds only when selectedTreeNodeIds changes. // Avoids O(N) Array.includes() in every visible TreeItem's isMultiSelected // computed during scrolling and selection changes. const selectedTreeNodeIdsSet = computed(() => new Set(selectedTreeNodeIds.value)); const treeSelectionAnchorId = ref(null); const connectionMultiSelectActive = ref(false); const treeClipboard = ref(null); watch(activeConnectionId, (id) => { if (id) localStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, id); else localStorage.removeItem(ACTIVE_CONNECTION_STORAGE_KEY); }); const treeNodes = ref([]); const sidebarDatabaseStorageCache = new Map(); const sidebarDatabaseStorageInFlight = new Map>(); const sidebarTableStorageCache = new Map(); const sidebarTableStorageInFlight = new Map>(); const pinnedTreeNodeOrder = ref([]); const pinnedTreeNodeIds = ref>(new Set()); const activePinnedTreeNodeReorderKey = ref(null); let pinnedTreeNodePersistQueue: Promise = Promise.resolve(); const connectedIds = ref>(new Set()); const identifierQuotes = ref>({}); const lastConnectionHealthCheckAt = ref>({}); const agentDrivers = ref([]); let agentDriversRefreshPromise: Promise | null = null; let localAgentDriversRefreshPromise: Promise | null = null; const loadedTreeNodeChildrenIds = ref>(new Set()); /** Simple-mode database/schema nodes loaded successfully with zero objects (not refresh stale shells). */ const confirmedEmptyTreeNodeIds = ref>(new Set()); const connectionErrors = ref>({}); const connectingIds = ref>(new Set()); const editingConnectionId = ref(null); const newConnectionGroupId = ref(null); const completionTablesCache = ref>({}); const completionObjectsCache = ref>({}); const completionColumnsCache = ref>({}); const completionForeignKeysCache = ref>({}); const completionDatabasesCache = ref>({}); const primaryVisibleObjectNames = ref>({}); const sqlServerCompletionContextCache = ref>({}); const elasticsearchCompletionIndicesCache = ref>({}); const redisCompletionKeysCache = ref>({}); const mongoCompletionCollectionsCache = ref>({}); const mongoCompletionFieldsCache = ref>({}); const schemaListCache = ref>({}); const sidebarSearchQuery = ref(""); const sidebarTableSearchQueries = ref>({}); const sidebarTableNameFilters = ref>(loadSidebarTableNameFilters()); const sidebarTableNameFilterRevisions = new Map(); const completionTableIndex = new Map(); const completionObjectIndex = new Map(); const completionColumnIndex = new Map(); const completionForeignKeyIndex = new Map(); const completionInFlight = new Map>(); const completionMetadataLimiter = new MetadataTaskLimiter(COMPLETION_METADATA_CONCURRENCY, (event) => { console.debug("[DBX][completion-metadata:limit]", event); }); const transferSource = ref<{ connectionId: string; database: string; catalog?: string; schema?: string; tables?: string[]; targetConnectionId?: string; targetDatabase?: string; targetSchema?: string; } | null>(null); const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null); const dataCompareSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string; } | null>(null); const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string } | null>(null); const diagramSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string; } | null>(null); const docsSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string; } | null>(null); const tableImportSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string; } | null>(null); const tableDataGenerateSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string; } | null>(null); const fieldLineageSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string; columnName: string; } | null>(null); const databaseSearchSource = ref<{ connectionId: string; database: string; schema?: string; } | null>(null); const databaseExportSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string; tableNames?: string[]; allDatabases?: boolean; } | null>(null); const sidebarLayout = ref(emptyLayout()); const connectionGroupPaths = computed(() => buildConnectionGroupPathMap(sidebarLayout.value)); let layoutPersistTimer: ReturnType | null = null; const staleTreeRefreshIds = new Set(); const activeTreeRefreshGenerations = new Map(); let nextTreeRefreshGeneration = 0; const metadataLoadCoordinator = new MetadataLoadCoordinator((event) => { console.debug("[DBX][metadata-load:coordinator]", event); }); const metadataListPageCache = new MetadataResultCache({ ttlMs: METADATA_LIST_PAGE_CACHE_TTL_MS, maxEntries: METADATA_LIST_PAGE_CACHE_MAX_ENTRIES, }); const metadataTraceLogger: MetadataLoadTraceLogger = (event) => { console.debug("[DBX][metadata-load:trace]", event); }; const connectInFlight = new Map>(); const disconnectInFlight = new Map>(); const disconnectInFlightScoped = new Map(); const cancelDisconnectInFlight = new Map>(); const activeLocalConnectionAttempts = new Map(); const cancelledLocalConnectionAttempts = new Map>(); const successfulLocalConnectionAttempts = new Map(); const connectionStateRevisions = new Map(); const connectionErrorRevisions = new Map(); const treeNodeLoads = new TreeNodeLoadRegistry(); const primaryVisibleObjectRefreshInFlight = new Set(); let nextLocalConnectionAttempt = 0; let beforeConnectHandler: BeforeConnectHandler | null = null; let initFromDiskPromise: Promise | null = null; // Loading/stale ownership stays on TreeNodeLoadRegistry (per-node generation), not the // coordinator: many specialty loaders bypass runTreeMetadataLoad, and coordinator would // otherwise need TreeNode/connected awareness. Keep coordinator for scope dedupe only. // connectionStateRevision remains for disconnect/error cleanup; reconnect also invalidates // tree loads via bump → treeNodeLoads.invalidateConnection. function runTreeMetadataLoad(scope: MetadataScopeInput, task: () => Promise, options?: LoadTreeOptions): Promise { return metadataLoadCoordinator.run(scope, task, { force: options?.force, kind: scope.kind }); } async function loadCachedMetadataListPage(scope: MetadataScopeInput, load: () => Promise, options?: { force?: boolean }): Promise { const trace = createMetadataLoadTrace(scope); if (!options?.force) { const cached = metadataListPageCache.get(scope); if (cached) { logMetadataLoadTrace(metadataTraceLogger, trace, "cache-hit", { cacheStatus: cached.stale ? "stale" : "hit", resultCount: cached.value.length, stale: cached.stale, }); return cached.value as T; } } logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: options?.force ? "refresh" : "miss", force: options?.force === true }); const errorRevision = connectionErrorRevision(scope.connectionId); const result = await load(); clearConnectionErrorIfUnchanged(scope.connectionId, errorRevision); metadataListPageCache.set(scope, result); logMetadataLoadTrace(metadataTraceLogger, trace, "done", { cacheStatus: options?.force ? "refresh" : "miss", resultCount: result.length, force: options?.force === true, }); return result; } function startEditing(id: string) { editingConnectionId.value = id; } function stopEditing() { editingConnectionId.value = null; } function startCreatingConnectionInGroup(groupId: string) { stopEditing(); newConnectionGroupId.value = groupId; } function stopCreatingConnectionInGroup() { newConnectionGroupId.value = null; } const configById = computed(() => new Map(connections.value.map((c) => [c.id, c]))); function getConfig(connectionId: string) { return configById.value.get(connectionId); } function connectionIdentifierQuote(connectionId?: string): string | undefined { if (!connectionId) return undefined; const override = gaussdbIdentifierQuoteOverride(getConfig(connectionId)); if (override != null) return override; return identifierQuotes.value[connectionId]; } function clearConnectionIdentifierQuote(connectionId: string) { if (!(connectionId in identifierQuotes.value)) return; const next = { ...identifierQuotes.value }; delete next[connectionId]; identifierQuotes.value = next; } async function refreshConnectionIdentifierQuote(connectionId: string, config: ConnectionConfig) { clearConnectionIdentifierQuote(connectionId); if (!connectionShouldLoadIdentifierQuote(config)) return; const quote = await api.connectionIdentifierQuote(connectionId).catch(() => undefined); if (quote != null) identifierQuotes.value = { ...identifierQuotes.value, [connectionId]: quote }; } function connectionErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } function isSupersededConnectionAttempt(error: unknown): boolean { return connectionErrorMessage(error).includes(SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE); } function isCancelledConnectionAttempt(error: unknown): boolean { return connectionErrorMessage(error).includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE); } function beginLocalConnectionAttempt(connectionId: string): number { const attempt = ++nextLocalConnectionAttempt; bumpConnectionStateRevision(connectionId); activeLocalConnectionAttempts.set(connectionId, attempt); connectingIds.value.add(connectionId); const node = findConnectionNode(connectionId); if (node) node.isLoading = true; return attempt; } function markSuccessfulLocalConnectionAttempt(connectionId: string, attempt: number) { successfulLocalConnectionAttempts.set(connectionId, attempt); } function forgetSuccessfulLocalConnectionAttempt(connectionId: string) { successfulLocalConnectionAttempts.delete(connectionId); } function bumpConnectionStateRevision(connectionId: string): number { const revision = (connectionStateRevisions.get(connectionId) ?? 0) + 1; connectionStateRevisions.set(connectionId, revision); // Invalidate per-node load generations under this connection (and clear sticky // spinners on surviving nodes). Active loaders reclaim after ensureConnected. treeNodeLoads.invalidateConnection(connectionId, findConnectionNode(connectionId)); return revision; } function connectionStateRevision(connectionId: string): number { return connectionStateRevisions.get(connectionId) ?? 0; } function isCurrentConnectionStateRevision(connectionId: string, revision: number): boolean { return connectionStateRevision(connectionId) === revision; } function isCurrentLocalConnectionAttempt(connectionId: string, attempt: number): boolean { return activeLocalConnectionAttempts.get(connectionId) === attempt; } function isCancelledLocalConnectionAttempt(connectionId: string, attempt: number): boolean { return cancelledLocalConnectionAttempts.get(connectionId)?.has(attempt) === true; } function getLocalConnectionAttempt(connectionId: string): number | undefined { return activeLocalConnectionAttempts.get(connectionId); } function finishLocalConnectionAttempt(connectionId: string, attempt: number) { if (isCancelledLocalConnectionAttempt(connectionId, attempt)) { const attempts = cancelledLocalConnectionAttempts.get(connectionId); attempts?.delete(attempt); if (attempts?.size === 0) { cancelledLocalConnectionAttempts.delete(connectionId); } } if (!isCurrentLocalConnectionAttempt(connectionId, attempt)) return; activeLocalConnectionAttempts.delete(connectionId); connectingIds.value.delete(connectionId); clearConnectionNodeLoading(connectionId); } function cancelLocalConnectionAttempt(connectionId: string): boolean { const attempt = activeLocalConnectionAttempts.get(connectionId); if (attempt == null) return false; const attempts = cancelledLocalConnectionAttempts.get(connectionId) ?? new Set(); attempts.add(attempt); cancelledLocalConnectionAttempts.set(connectionId, attempts); activeLocalConnectionAttempts.delete(connectionId); connectingIds.value.delete(connectionId); clearConnectionNodeLoading(connectionId); clearConnectionRootMetadataLoad(connectionId); connectInFlight.delete(connectionId); return true; } function clearConnectionRootMetadataLoad(connectionId: string) { metadataLoadCoordinator.clear({ kind: "connection-databases", connectionId, driverProfile: metadataDriverProfile(getConfig(connectionId)), }); } function getBlockingDisconnectInFlight(connectionId: string): Promise | undefined { return disconnectInFlightScoped.get(connectionId) ? undefined : disconnectInFlight.get(connectionId); } async function waitForBlockingDisconnectInFlight(connectionId: string): Promise { const pending = getBlockingDisconnectInFlight(connectionId); if (pending) await pending; } function trackDisconnectRequest(connectionId: string, request: Promise, scoped: boolean): Promise { const bounded = withDisconnectRequestTimeout(connectionId, request); const tracked = bounded .catch((error) => { console.warn("[DBX][connection:disconnect-error]", { connectionId, error }); }) .finally(() => { if (disconnectInFlight.get(connectionId) === tracked) { disconnectInFlight.delete(connectionId); disconnectInFlightScoped.delete(connectionId); } }); disconnectInFlight.set(connectionId, tracked); disconnectInFlightScoped.set(connectionId, scoped); return bounded; } function startDisconnectRequest(connectionId: string): Promise { const clientAttempt = activeLocalConnectionAttempts.get(connectionId) ?? successfulLocalConnectionAttempts.get(connectionId); let request: Promise; try { request = api.disconnectDb(connectionId, clientAttempt); } catch (error) { request = Promise.reject(error); } return trackDisconnectRequest(connectionId, request, clientAttempt != null); } function cancelDisconnectKey(connectionId: string, attempt: number): string { return `${connectionId}:${attempt}`; } function startCancelDisconnectRequest(connectionId: string, attempt: number): Promise { const key = cancelDisconnectKey(connectionId, attempt); const existing = cancelDisconnectInFlight.get(key); if (existing) return existing; let request: Promise; try { request = api.disconnectDb(connectionId, attempt); } catch (error) { request = Promise.reject(error); } const tracked = withDisconnectRequestTimeout(connectionId, request) .catch((error) => { console.warn("[DBX][connection:cancel-disconnect-error]", { connectionId, attempt, error }); throw error; }) .finally(() => { if (cancelDisconnectInFlight.get(key) === tracked) { cancelDisconnectInFlight.delete(key); } }); cancelDisconnectInFlight.set(key, tracked); return tracked; } async function cleanupResolvedCancelledConnectionAttempt(connectionId: string, attempt: number) { try { // A cancel request can reach the backend before connect_db registers the // attempt, so clean again if that cancelled connect later returns a pool. await withDisconnectRequestTimeout(connectionId, api.disconnectDb(connectionId, attempt)); } catch (error) { console.warn("[DBX][connection:cancel-result-cleanup-error]", { connectionId, attempt, error }); } } async function ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId: string, attempt: number, cleanupConnectionId: string) { if (isCancelledLocalConnectionAttempt(connectionId, attempt)) { await cleanupResolvedCancelledConnectionAttempt(cleanupConnectionId, attempt); throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE); } ensureLocalConnectionAttemptActive(connectionId, attempt); } function ensureLocalConnectionAttemptActive(connectionId: string, attempt: number) { if (isCancelledLocalConnectionAttempt(connectionId, attempt)) { throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE); } if (!isCurrentLocalConnectionAttempt(connectionId, attempt)) { throw new Error(SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE); } } function setConnectionError(connectionId: string, message: string) { connectionErrors.value[connectionId] = message; connectionErrorRevisions.set(connectionId, connectionErrorRevision(connectionId) + 1); } function updateConnectionErrorPresentation(connectionId: string, expectedMessage: string, message: string) { // Hints decorate the current error event and must not invalidate a retry's recovery revision. if (connectionErrors.value[connectionId] !== expectedMessage) return; connectionErrors.value[connectionId] = message; } function connectionErrorRevision(connectionId?: string | null): number { return connectionId ? (connectionErrorRevisions.get(connectionId) ?? 0) : 0; } function clearConnectionErrorIfUnchanged(connectionId: string | null | undefined, revision: number) { if (!connectionId || connectionErrorRevision(connectionId) !== revision) return; clearConnectionError(connectionId); } function agentDriverUpdateHint(): string { return i18n.global.t("connection.agentDriverUpdateConnectionHint"); } function connectionErrorWithDriverUpdateHint(config: ConnectionConfig | undefined, message: string): string { if (!config) return message; message = appendConnectionErrorHints(config, message, i18n.global.t); if (!hasAgentDriverUpdate(config.db_type, agentDrivers.value, config.driver_profile)) return message; return appendAgentDriverUpdateHint(message, agentDriverUpdateHint()); } function refreshAgentDriversForErrorHint(): Promise { if (agentDriversRefreshPromise) return agentDriversRefreshPromise; agentDriversRefreshPromise = api .listInstalledAgents() .then((drivers) => { agentDrivers.value = drivers; }) .catch(() => undefined) .finally(() => { agentDriversRefreshPromise = null; }); return agentDriversRefreshPromise; } function refreshLocalAgentDrivers(): Promise { if (localAgentDriversRefreshPromise) return localAgentDriversRefreshPromise; localAgentDriversRefreshPromise = api .listInstalledAgentsLocal() .then((drivers) => { agentDrivers.value = drivers; }) .catch(() => undefined) .finally(() => { localAgentDriversRefreshPromise = null; }); return localAgentDriversRefreshPromise; } async function supportsXuguTableChildMetadata(): Promise { if (!hasInstalledAgentVersion(agentDrivers.value, "xugu", XUGU_TABLE_CHILD_METADATA_AGENT_VERSION)) { await refreshLocalAgentDrivers(); } return hasInstalledAgentVersion(agentDrivers.value, "xugu", XUGU_TABLE_CHILD_METADATA_AGENT_VERSION); } function maybeAppendAgentDriverUpdateHint(connectionId: string, baseMessage: string) { const config = getConfig(connectionId); const message = connectionErrorWithDriverUpdateHint(config, baseMessage); if (message !== baseMessage) { updateConnectionErrorPresentation(connectionId, baseMessage, message); return; } void refreshAgentDriversForErrorHint().then(() => { if (connectionErrors.value[connectionId] !== baseMessage) return; const refreshedMessage = connectionErrorWithDriverUpdateHint(config, baseMessage); if (refreshedMessage !== baseMessage) updateConnectionErrorPresentation(connectionId, baseMessage, refreshedMessage); }); } function clearConnectionError(connectionId: string) { if (!connectionErrors.value[connectionId]) return; delete connectionErrors.value[connectionId]; connectionErrorRevisions.set(connectionId, connectionErrorRevision(connectionId) + 1); } function markConnectionHealthChecked(connectionId: string) { lastConnectionHealthCheckAt.value[connectionId] = Date.now(); } function clearConnectionHealthCheck(connectionId: string) { if (!lastConnectionHealthCheckAt.value[connectionId]) return; delete lastConnectionHealthCheckAt.value[connectionId]; } function hasRecentConnectionHealthCheck(connectionId: string) { const checkedAt = lastConnectionHealthCheckAt.value[connectionId]; return typeof checkedAt === "number" && Date.now() - checkedAt < CONNECTION_HEALTH_CHECK_TTL_MS; } function clearConnectionNodeLoading(connectionId: string) { const node = findConnectionNode(connectionId); if (node) node.isLoading = false; } function metadataLoadTimeoutMs(config?: ConnectionConfig): number { const queryTimeoutSecs = Number(config?.query_timeout_secs); if (queryTimeoutSecs === 0) return METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS; const boundedTimeoutSecs = Number.isFinite(queryTimeoutSecs) && queryTimeoutSecs > 0 ? queryTimeoutSecs + 5 : 35; return Math.max(METADATA_LOAD_MIN_TIMEOUT_MS, boundedTimeoutSecs * 1000); } async function withConnectionHealthTimeout(connectionId: string, promise: Promise): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error(`Connection health check timed out after ${Math.ceil(CONNECTION_HEALTH_CHECK_TIMEOUT_MS / 1000)}s.`)); }, CONNECTION_HEALTH_CHECK_TIMEOUT_MS); }), ]); } catch (error) { clearConnectionNodeLoading(connectionId); throw error; } finally { if (timer) clearTimeout(timer); } } async function withMetadataLoadTimeout(connectionId: string, promise: Promise, label: string): Promise { const timeoutMs = metadataLoadTimeoutMs(getConfig(connectionId)); const errorRevision = connectionErrorRevision(connectionId); let timer: ReturnType | undefined; try { const result = await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error(`Connection timed out while loading ${label} after ${Math.ceil(timeoutMs / 1000)}s. Please check the network or VPN and try again.`)); }, timeoutMs); }), ]); clearConnectionErrorIfUnchanged(connectionId, errorRevision); return result; } finally { if (timer) clearTimeout(timer); } } async function withDisconnectRequestTimeout(connectionId: string, promise: Promise): Promise { let timedOut = false; let timer: ReturnType | undefined; void promise.catch((error) => { if (timedOut) console.warn("[DBX][connection:disconnect-late-error]", { connectionId, error }); }); try { await Promise.race([ promise, new Promise((resolve) => { timer = setTimeout(() => { timedOut = true; console.warn("[DBX][connection:disconnect-timeout]", { connectionId, timeoutMs: DISCONNECT_REQUEST_TIMEOUT_MS }); resolve(); }, DISCONNECT_REQUEST_TIMEOUT_MS); }), ]); } finally { if (timer) clearTimeout(timer); } } function recordConnectionError(connectionId: string, error: unknown): string { const message = connectionErrorMessage(error); if (isCancelledConnectionAttempt(message)) { clearConnectionError(connectionId); return ""; } setConnectionError(connectionId, message); maybeAppendAgentDriverUpdateHint(connectionId, message); return message; } function markConnectionLost(connectionId: string, error: unknown) { connectedIds.value.delete(connectionId); clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); clearConnectionNodeLoading(connectionId); clearConnectionHealthCheck(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; recordConnectionError(connectionId, error); } function recordConnectionLostError(connectionId: string, error: unknown): boolean { if (shouldMarkDisconnected(error)) { markConnectionLost(connectionId, error); return true; } return false; } // Metadata loaders keep this internal: match connection-loss errors before recording generic errors. function recordMetadataLoadError(connectionId: string, error: unknown, load?: TreeNodeLoadHandle) { if (load && !load.isCurrent()) return; if (recordConnectionLostError(connectionId, error)) return; recordConnectionError(connectionId, error); } async function withConnectionAttemptTimeout(promise: Promise, config: ConnectionConfig): Promise { const timeoutMs = connectionAttemptTimeoutMs(config, tunnelProfileStore.profileById); const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs); let timedOut = false; let timer: ReturnType | undefined; void promise.then( (connectionId) => { if (!timedOut) return; const cleanupConnectionId = typeof connectionId === "string" && connectionId ? connectionId : config.id; if (connectedIds.value.has(cleanupConnectionId)) return; void api.disconnectDb(cleanupConnectionId).catch((error) => { console.warn("[DBX][connection:timeout-cleanup-failed]", { connectionId: cleanupConnectionId, error }); }); }, (error) => { if (!timedOut) return; const current = connectionErrors.value[config.id]; if (current !== timeoutMessage) return; setConnectionError(config.id, connectionAttemptOriginalErrorMessage(timeoutMessage, connectionErrorMessage(error))); }, ); try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => { timedOut = true; reject(new Error(timeoutMessage)); }, timeoutMs); }), ]); } finally { if (timer) clearTimeout(timer); } } function normalizeConnection(config: ConnectionConfig): ConnectionConfig { config = { ...config }; migrateSqlServerLegacyCompatibilityConfig(config); const connectTimeoutInherit = config.connect_timeout_inherit ?? settingsStore.editorSettings.connectTimeoutInheritConnectionIds.includes(config.id); const queryTimeoutInherit = config.query_timeout_inherit ?? settingsStore.editorSettings.queryTimeoutInheritConnectionIds.includes(config.id); const labelMap: Record = { mysql: "MySQL", postgres: "PostgreSQL", sqlite: "SQLite", redis: "Redis", etcd: "etcd", zookeeper: "Apache ZooKeeper", consul: "Consul", duckdb: "DuckDB", clickhouse: "ClickHouse", sqlserver: "SQL Server", mongodb: "MongoDB", oracle: "Oracle", "mongodb-legacy": MONGO_LEGACY_DRIVER_LABEL, elasticsearch: "Elasticsearch", easysearch: "Easysearch", qdrant: "Qdrant", milvus: "Milvus", weaviate: "Weaviate", chromadb: "ChromaDB", doris: "Doris", starrocks: "StarRocks", manticoresearch: "Manticore Search", redshift: "Redshift", dameng: "达梦 Dameng", gaussdb: "GaussDB", questdb: "QuestDB", kwdb: "KWDB", kingbase: "人大金仓 KingbaseES", highgo: "瀚高 HighGo", uxdb: "优炫 UXDB", yashandb: "崖山 YashanDB", vastbase: "海量 Vastbase", goldendb: "金篆 GoldenDB", access: "Microsoft Access", h2: "H2", snowflake: "Snowflake", trino: "Trino", prestosql: "PrestoSQL", hive: "Hive", spark: "Apache Spark", db2: "DB2", informix: "Informix", phoenix: "Apache Phoenix", neo4j: "Neo4j", cassandra: "Cassandra", bigquery: "BigQuery", kylin: "Kylin", sundb: "科蓝 SUNDB", oscar: "神通 OSCAR", influxdb: "InfluxDB", victoriametrics: "VictoriaMetrics", }; const profile = config.driver_profile || config.db_type; let dbType = config.db_type; if ((profile === "gaussdb" || profile === "opengauss") && dbType === "postgres") { dbType = "gaussdb" as ConnectionConfig["db_type"]; } else if (profile === "kwdb" && dbType === "postgres") { dbType = "kwdb" as ConnectionConfig["db_type"]; } else if (profile === "questdb" && dbType === "postgres") { dbType = "questdb" as ConnectionConfig["db_type"]; } else if (profile === "redshift" && dbType === "postgres") { dbType = "redshift" as ConnectionConfig["db_type"]; } else if (profile === "kingbase" && dbType === "postgres") { dbType = "kingbase" as ConnectionConfig["db_type"]; } else if (profile === "highgo" && dbType === "postgres") { dbType = "highgo" as ConnectionConfig["db_type"]; } else if (profile === "uxdb" && dbType === "postgres") { dbType = "uxdb" as ConnectionConfig["db_type"]; } else if (profile === "vastbase" && dbType === "postgres") { dbType = "vastbase" as ConnectionConfig["db_type"]; } else if (profile === "goldendb" && dbType === "mysql") { dbType = "goldendb" as ConnectionConfig["db_type"]; } return { ...config, db_type: dbType, driver_profile: profile, driver_label: config.driver_label || labelMap[profile] || config.db_type, url_params: config.url_params || "", agent_java_options: Array.isArray(config.agent_java_options) ? config.agent_java_options : [], attached_databases: Array.isArray(config.attached_databases) ? config.attached_databases.filter((database) => database.name?.trim() && database.path?.trim()) : [], init_script: config.init_script?.trim() ? config.init_script : undefined, // A cleared field must become absent, not "". `resolve_notes_path` treats // blank as unset anyway, but an empty string would still be written to // the config file as though a path had been chosen. docs_notes_path: config.docs_notes_path?.trim() ? config.docs_notes_path.trim() : undefined, transport_layers: Array.isArray(config.transport_layers) ? config.transport_layers : [], show_system_schemas: config.show_system_schemas === true, connect_timeout_secs: connectTimeoutInherit ? settingsStore.editorSettings.globalConnectTimeoutSecs : config.connect_timeout_secs || 10, connect_timeout_inherit: connectTimeoutInherit, query_timeout_secs: queryTimeoutInherit ? settingsStore.editorSettings.globalQueryTimeoutSecs : (config.query_timeout_secs ?? 30), query_timeout_inherit: queryTimeoutInherit, idle_timeout_secs: config.idle_timeout_secs ?? 60, keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS, redis_database_aliases: normalizeRedisDatabaseAliases(config.redis_database_aliases), database_info: normalizeDatabaseConnectionInfo(config.database_info), }; } function loadPinnedTreeNodeOrderFromLocalStorage(): string[] { try { if (typeof localStorage === "undefined") return []; const saved = localStorage.getItem(PINNED_TREE_NODES_STORAGE_KEY); const ids = saved ? JSON.parse(saved) : []; return normalizePinnedTreeNodeOrder(Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : []); } catch { return []; } } async function loadPinnedTreeNodeOrder(): Promise { if (!isDesktop) return loadPinnedTreeNodeOrderFromLocalStorage(); const ids = await api.loadPinnedTreeNodeIds().catch(() => []); const valid = normalizePinnedTreeNodeOrder(ids.filter((id): id is string => typeof id === "string")); if (valid.length > 0) return valid; // Migrate legacy localStorage values for existing desktop users. const legacy = loadPinnedTreeNodeOrderFromLocalStorage(); if (legacy.length > 0) { await api.savePinnedTreeNodeIds(legacy).catch(() => undefined); if (typeof localStorage !== "undefined") { localStorage.removeItem(PINNED_TREE_NODES_STORAGE_KEY); } } return legacy; } function setPinnedTreeNodeOrder(order: readonly string[]) { const normalized = normalizePinnedTreeNodeOrder(order); pinnedTreeNodeOrder.value = normalized; pinnedTreeNodeIds.value = new Set(normalized); } function persistPinnedTreeNodeIds() { const snapshot = [...pinnedTreeNodeOrder.value]; if (isDesktop) { // A later drag must never be persisted before an earlier request finishes: // otherwise a slow old request can overwrite the final ordering on disk. pinnedTreeNodePersistQueue = pinnedTreeNodePersistQueue.catch(() => undefined).then(() => api.savePinnedTreeNodeIds(snapshot).catch(() => undefined)); return; } if (typeof localStorage === "undefined") return; localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify(snapshot)); } function findLoadedTreeNodeById(nodes: readonly TreeNode[], id: string): TreeNode | null { for (const node of nodes) { if (node.id === id) return node; const child = node.children ? findLoadedTreeNodeById(node.children, id) : null; if (child) return child; const hiddenChild = node.hiddenChildren ? findLoadedTreeNodeById(node.hiddenChildren, id) : null; if (hiddenChild) return hiddenChild; } return null; } function isTreeNodePinned(node: TreeNode | string): boolean { if (typeof node !== "string") return pinnedTreeNodeIds.value.has(treeNodePinKey(node)) || pinnedTreeNodeIds.value.has(node.id); if (pinnedTreeNodeIds.value.has(node)) return true; const loadedNode = findLoadedTreeNodeById(treeNodes.value, node); return !!loadedNode && pinnedTreeNodeIds.value.has(treeNodePinKey(loadedNode)); } function isFixedPriorityTreeNode(node: TreeNode): boolean { if (node.type === "schema") { return !!node.connectionId && !!node.schema && isDefaultSchema(node.connectionId, node.schema); } if (node.type !== "database" && node.type !== "redis-db" && node.type !== "mongo-db") return false; return !!node.connectionId && typeof node.database === "string" && isDefaultDatabase(node.connectionId, node.database); } function orderByPinnedTreeNodes(items: readonly T[], matches: (item: T, identity: PinnedTreeNodeIdentity) => boolean): T[] { return orderItemsByPinnedTreeNodeOrder(items, pinnedTreeNodeOrder.value, matches, treeNodes.value); } function syncPinnedTreeState(nodes: TreeNode[]) { syncPinnedTreeNodeStateInPlace(nodes, pinnedTreeNodeIds.value, pinnedTreeNodeOrder.value, isFixedPriorityTreeNode); } function isConnectionUtilityNode(node: TreeNode): boolean { return node.type === "user-admin" || node.type === "dameng-job-admin"; } function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] { return (children || []).filter((child) => !isConnectionUtilityNode(child)); } function hasConnectionMetadataChildren(children: TreeNode[] | undefined): boolean { return connectionMetadataChildren(children).length > 0; } function preserveExistingConnectionMetadataChildren(parent: TreeNode, children: TreeNode[]): TreeNode[] { if (parent.type !== "connection" || hasConnectionMetadataChildren(children)) return children; const existingMetadataChildren = connectionMetadataChildren(parent.children); const nextUtilityChildren = children.filter(isConnectionUtilityNode); if (existingMetadataChildren.length === 0 || nextUtilityChildren.length === 0) return children; return [...existingMetadataChildren, ...nextUtilityChildren]; } // Leaf tree nodes (table columns / indexes / foreign keys / triggers) are // immutable data payloads: they never expand, never load children, and their // fields are never mutated after creation. A large schema can produce tens of // thousands of them, and Vue's deep reactivity wraps every node AND its nested // `meta` object in a Proxy — the dominant memory cost of the schema tree. // Marking each leaf raw keeps Vue from wrapping it (and, since Vue does not // recurse into raw objects, its `meta` too), mirroring the markRaw() treatment // queryStore already applies to result rows. Containers stay reactive so their // children / isExpanded / isLoading mutations still drive the UI. const LEAF_TREE_NODE_TYPES = new Set(["column", "index", "fkey", "trigger"]); function markRawLeafTreeNodes(nodes: TreeNode[]): TreeNode[] { for (const node of nodes) { if (LEAF_TREE_NODE_TYPES.has(node.type)) { markRaw(node); } else if (node.children && node.children.length > 0) { markRawLeafTreeNodes(node.children); } } return nodes; } function clearDescendantLoadedChildrenMarkers(parentId: string) { const descendantPrefix = `${parentId}:`; for (const id of loadedTreeNodeChildrenIds.value) { if (id.startsWith(descendantPrefix)) loadedTreeNodeChildrenIds.value.delete(id); } for (const id of confirmedEmptyTreeNodeIds.value) { if (id.startsWith(descendantPrefix)) confirmedEmptyTreeNodeIds.value.delete(id); } } /** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */ function forgetTreeNodeLoadState(nodeId: string) { clearLoadedChildrenCache(nodeId); treeNodeLoads.invalidatePrefix(nodeId); } function syncConfirmedEmptyTreeNodeId(parent: TreeNode) { if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return; const childCount = parent.children?.length ?? 0; if (childCount === 0) confirmedEmptyTreeNodeIds.value.add(parent.id); else confirmedEmptyTreeNodeIds.value.delete(parent.id); } function sameConnectionMetadataChildIds(existing: TreeNode[] | undefined, next: TreeNode[]): boolean { const previousIds = new Set(connectionMetadataChildren(existing).map((child) => child.id)); const nextIds = new Set(connectionMetadataChildren(next).map((child) => child.id)); if (previousIds.size !== nextIds.size) return false; for (const id of previousIds) { if (!nextIds.has(id)) return false; } return true; } function directChildIdWasRemoved(existing: TreeNode[] | undefined, next: TreeNode[]): boolean { const nextIds = new Set(next.map((child) => child.id)); for (const child of existing ?? []) { // Pagination placeholders are replaced on every page fetch, not structural removals. if (child.type === "load-more") continue; if (!nextIds.has(child.id)) return true; } return false; } function shouldClearDescendantLoadedMarkers(parent: TreeNode, nextChildren: TreeNode[]): boolean { if (parent.type === "connection") { return !sameConnectionMetadataChildIds(parent.children, nextChildren); } if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema" || objectTypesForGroupNode(parent.type)) { return directChildIdWasRemoved(parent.children, nextChildren); } return false; } function setChildren(parent: TreeNode, children: TreeNode[]) { // Compare markers against the resolved child list (after connection preserve), not the raw loader payload. children = preserveExistingConnectionMetadataChildren(parent, children); if (shouldClearDescendantLoadedMarkers(parent, children)) { clearDescendantLoadedChildrenMarkers(parent.id); // Parent load may still be current; only supersede descendant generations. treeNodeLoads.invalidateDescendants(parent.id); } if (parent.children && parent.children.length > 0) { const oldMap = new Map(parent.children.map((c) => [c.id, c] as const)); const nextIds = new Set(children.map((child) => child.id)); for (const [oldId, old] of oldMap) { // Removed children keep no loaded markers; also bump generations so in-flight // loads cannot apply if the same id is recreated later. if (!nextIds.has(oldId) && old.type !== "load-more") { forgetTreeNodeLoadState(oldId); } } children = children.map((child) => { const old = oldMap.get(child.id); if (old?.isLoading) { const isExpanded = old.isExpanded; const isLoading = old.isLoading; const oldChildren = old.children; const objectCount = child.objectCount ?? old.objectCount; Object.assign(old, child); old.isExpanded = isExpanded; old.isLoading = isLoading; old.children = oldChildren; old.objectCount = objectCount; return old; } if (old?.isExpanded) { return { ...child, isExpanded: true, children: old.children, objectCount: child.objectCount ?? old.objectCount }; } if (old && objectTypesForGroupNode(old.type)) { return { ...child, objectCount: child.objectCount ?? old.objectCount }; } // Same-id collapsed database/schema shell replace (e.g. DDL → force loadDatabases): // prior confirmed-empty markers belong to the discarded instance and must not skip // the next expand reload. Do not do this for tables/groups — load-more and list // refresh must preserve nested loaded markers (columns, etc.). if (old && (old.type === "database" || old.type === "schema" || old.type === "linked-server-schema")) { forgetTreeNodeLoadState(child.id); } return child; }); } const migratedPins = migrateLegacyPinnedTreeNodeOrder(children, pinnedTreeNodeOrder.value); if (migratedPins.changed) { setPinnedTreeNodeOrder(migratedPins.order); persistPinnedTreeNodeIds(); } syncPinnedTreeState(children); parent.children = markRawLeafTreeNodes(children); loadedTreeNodeChildrenIds.value.add(parent.id); syncConfirmedEmptyTreeNodeId(parent); } function removePinnedTreeNodes(nodes: readonly TreeNode[], canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): boolean { const nextPinnedOrder = removePinnedTreeNodesFromOrder(pinnedTreeNodeOrder.value, nodes, canonicalize, legacyKeys); if (nextPinnedOrder.length === pinnedTreeNodeOrder.value.length && nextPinnedOrder.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false; setPinnedTreeNodeOrder(nextPinnedOrder); syncPinnedTreeState(treeNodes.value); persistPinnedTreeNodeIds(); return true; } function replacePinnedTreeNode(oldNode: TreeNode, newNode: TreeNode, canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): boolean { // Use the freshly loaded sidebar node when available so the persisted key // carries its real id, not the id of the pre-rename object. const loadedReplacement = findTreeNodes(treeNodes.value, (node) => pinnedTreeNodeIdentityMatches(treeNodePinIdentity(node), treeNodePinIdentity(newNode), canonicalize))[0]; // A caller may provide a virtual row while the sidebar object is unloaded; // persisting that row id would create a pin that the sidebar cannot restore. const nextPinnedOrder = loadedReplacement ? replacePinnedTreeNodeInOrder(pinnedTreeNodeOrder.value, oldNode, loadedReplacement, canonicalize, legacyKeys) : removePinnedTreeNodesFromOrder(pinnedTreeNodeOrder.value, [oldNode], canonicalize, legacyKeys); if (nextPinnedOrder.length === pinnedTreeNodeOrder.value.length && nextPinnedOrder.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false; setPinnedTreeNodeOrder(nextPinnedOrder); syncPinnedTreeState(treeNodes.value); persistPinnedTreeNodeIds(); return true; } function removeTreeNode(nodeId: string) { const node = findNode(treeNodes.value, nodeId); if (node) removePinnedTreeNodes([node]); const parent = findParentNode(treeNodes.value, nodeId); if (parent?.children) { parent.children = parent.children.filter((c) => c.id !== nodeId); // Keep the group badge in sync with remaining real children (exclude load-more). if (parent.objectCount != null) { parent.objectCount = withoutLoadMoreNodes(parent.children).length; } } if (parent?.hiddenChildren) { parent.hiddenChildren = parent.hiddenChildren.filter((child) => child.id !== nodeId); } if (selectedTreeNodeId.value === nodeId) selectedTreeNodeId.value = null; selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => id !== nodeId); if (treeSelectionAnchorId.value === nodeId) treeSelectionAnchorId.value = null; } function buildUserAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined { const config = getConfig(connectionId); if (!connectionSupportsDatabaseUserAdmin(config)) return undefined; const existing = existingConnectionNode?.children?.find((child) => child.type === "user-admin"); return { id: `${connectionId}:__user_admin`, label: "tree.userAdmin", type: "user-admin", connectionId, database: "", isExpanded: existing?.isExpanded ?? false, }; } function buildDamengJobAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined { const config = getConfig(connectionId); if (effectiveDatabaseTypeForConnection(config) !== "dameng") return undefined; const existing = existingConnectionNode?.children?.find((child) => child.type === "dameng-job-admin"); return { id: `${connectionId}:__dameng_jobs`, label: "tree.damengJobAdmin", type: "dameng-job-admin", connectionId, database: "", isExpanded: existing?.isExpanded ?? false, }; } function withConnectionUtilityNodes(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { const nonUtilityChildren = connectionMetadataChildren(children); const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode); const damengJobAdminNode = buildDamengJobAdminNode(connectionId, existingConnectionNode); return [...nonUtilityChildren, userAdminNode, damengJobAdminNode].filter(Boolean) as TreeNode[]; } function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { return withConnectionUtilityNodes(connectionId, children, existingConnectionNode); } function schemaCacheKey(...parts: string[]): string { return parts.map((part) => encodeURIComponent(part)).join(":"); } function ownerAwareMetadataCacheVersion(config: ConnectionConfig | undefined, version: string): string { return config?.db_type === "informix" ? `${version}-informix-owner-v2` : version; } function supportedSidebarObjectTypes(config?: ConnectionConfig): DatabaseObjectTreeKind[] { const dbType = effectiveDatabaseTypeForConnection(config); return sidebarObjectKindsForDatabase(dbType); } function sortSidebarSchemaInfos(schemas: readonly SchemaInfo[]): SchemaInfo[] { const byName = new Map(); for (const schema of schemas) { const name = schema.name.trim(); if (!name) continue; byName.set(name, { name, comment: schema.comment ?? null }); } return sortSidebarNames([...byName.keys()]).map((name) => byName.get(name)!); } function buildExtensionManagementNode(connectionId: string, database: string): TreeNode { return { id: `${connectionId}:${database}:__extensions`, label: "tree.extensions", type: "group-extensions", connectionId, database, isExpanded: false, children: [], }; } function objectGroupCacheKey(node: TreeNode): string { const config = node.connectionId ? getConfig(node.connectionId) : undefined; const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "oracle" ? "objects-v7" : "objects-v6"); return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, cacheVersion); } function tableNameFilterScopeKey(parts: { connectionId?: string | null; database?: string | null; schema?: string | null; nodeKind?: string | null; catalog?: string | null }): string { return schemaCacheKey(parts.connectionId || "", parts.catalog || "", parts.database || "", parts.schema || "", parts.nodeKind || "group-tables"); } function tableNameFilterForScope(parts: { connectionId?: string | null; database?: string | null; schema?: string | null; nodeKind?: string | null; catalog?: string | null }): TableNameFilter | undefined { return sidebarTableNameFilters.value[tableNameFilterScopeKey(parts)]; } function activeTableNameFilterForScope(parts: { connectionId?: string | null; database?: string | null; schema?: string | null; nodeKind?: string | null; catalog?: string | null }): TableNameFilter | undefined { const filter = tableNameFilterForScope(parts); return tableNameFilterIsEmpty(filter) ? undefined : filter; } function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined): MetadataScopeInput["extra"] { return filter ? { tableNameFilterInclude: filter.includePatterns, tableNameFilterExclude: filter.excludePatterns, } : undefined; } function setSidebarTableNameFilter(scopeKey: string, filter: TableNameFilter) { const normalized = normalizeTableNameFilter(filter); const next = { ...sidebarTableNameFilters.value }; if (tableNameFilterIsEmpty(normalized)) delete next[scopeKey]; else next[scopeKey] = normalized; sidebarTableNameFilters.value = next; saveSidebarTableNameFilters(next); const revision = (sidebarTableNameFilterRevisions.get(scopeKey) ?? 0) + 1; sidebarTableNameFilterRevisions.set(scopeKey, revision); return revision; } function removeSidebarTableNameFiltersForConnections(connectionIds: Iterable) { const encodedPrefixes = [...connectionIds].map((connectionId) => `${encodeURIComponent(connectionId)}:`); if (encodedPrefixes.length === 0) return; let changed = false; const next = { ...sidebarTableNameFilters.value }; for (const key of Object.keys(next)) { if (!encodedPrefixes.some((prefix) => key.startsWith(prefix))) continue; delete next[key]; sidebarTableNameFilterRevisions.delete(key); changed = true; } if (!changed) return; sidebarTableNameFilters.value = next; saveSidebarTableNameFilters(next); } function tableNameFilterRevisionMatches(options?: LoadTreeOptions): boolean { if (!options?.tableNameFilterScopeKey) return true; return (sidebarTableNameFilterRevisions.get(options.tableNameFilterScopeKey) ?? 0) === options.expectedTableNameFilterRevision; } function listTablesWithOptionalTableNameFilter(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number, objectTypes?: DatabaseObjectTreeKind[], catalog?: string, tableNameFilter?: TableNameFilter) { if (tableNameFilter) return api.listTables(connectionId, database, schema, filter, limit, offset, objectTypes, catalog, tableNameFilter); if (catalog) return api.listTables(connectionId, database, schema, filter, limit, offset, objectTypes, catalog); if (objectTypes) return api.listTables(connectionId, database, schema, filter, limit, offset, objectTypes); return api.listTables(connectionId, database, schema, filter, limit, offset); } function metadataListDriverProfile(connectionId?: string): string | undefined { return connectionId ? metadataDriverProfile(getConfig(connectionId)) : undefined; } function metadataListCacheScope(options: { kind: string; connectionId?: string | null; database?: string | null; schema?: string | null; nodeKind?: string | null; objectTypes?: readonly string[] | null; searchFilter?: string | null; limit?: number | null; offset?: number | null; sidebarDisplayMode?: string | null; extra?: MetadataScopeInput["extra"]; }): MetadataScopeInput { return { kind: options.kind, connectionId: options.connectionId, database: options.database, schema: options.schema, nodeKind: options.nodeKind, objectTypes: options.objectTypes, searchFilter: options.searchFilter, limit: options.limit, offset: options.offset, sidebarDisplayMode: options.sidebarDisplayMode, driverProfile: metadataListDriverProfile(options.connectionId || undefined), extra: options.extra, }; } function invalidateMetadataCaches(match: MetadataCacheInvalidation): number { return metadataListPageCache.invalidate(match) + invalidateTableMetadataCache(match) + invalidateObjectBrowserRowsCache(match); } function invalidateMetadataCachesByTreePrefix(prefix: string) { const [connectionId, database, schema, tableName] = prefix.split(":").map((part) => { try { return decodeURIComponent(part); } catch { return part; } }); if (!connectionId) return; invalidateMetadataCaches({ connectionId, database: database || undefined, schema: schema || undefined, tableName: tableName && !tableName.startsWith("__") ? tableName : undefined, }); } function invalidateMetadataCachesForNode(node: TreeNode) { if (!node.connectionId) return; const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" ? node.label : undefined); const match = { connectionId: node.connectionId, database: node.database || undefined, schema: node.schema || undefined, tableName, }; invalidateMetadataCaches(match); void invalidateObjectDdlCache(match); } function invalidateMetadataCache(connectionId: string, database?: string, schema?: string, tableName?: string) { const match = { connectionId, database, schema, tableName }; invalidateMetadataCaches(match); void invalidateObjectDdlCache(match); } function buildLoadMoreNode(parent: TreeNode, offset: number, pageSize: number): TreeNode { return { id: `${parent.id}:__load_more:${offset}`, label: "tree.loadMore", type: "load-more", connectionId: parent.connectionId, database: parent.database, schema: parent.schema, isLoading: false, loadMore: { parentId: parent.id, offset, pageSize, }, }; } function withoutLoadMoreNodes(children: TreeNode[] | undefined): TreeNode[] { return (children || []).filter((child) => child.type !== "load-more"); } function objectGroupChildrenFromObjects(options: { node: TreeNode; parentNodeId: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; objects: ObjectInfo[] }): TreeNode[] { const grouped = buildGroupedObjectTreeNodes({ nodeId: options.parentNodeId, connectionId: options.node.connectionId || "", database: options.node.database || "", schema: options.effectiveSchema, objects: options.objects.filter((object) => options.objectTypes.includes(normalizedObjectTreeKind(object.object_type))), }); const refreshedGroup = grouped.find((group) => group.type === options.node.type); const children = refreshedGroup?.children ?? []; const databaseType = options.node.connectionId ? effectiveDatabaseTypeForConnection(getConfig(options.node.connectionId)) : undefined; return supportsPackageMemberExpansion(databaseType) ? markPackageNodesExpandable(children) : children; } function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string): SqlCompletionTable[] { return tables.map((table) => ({ name: table.name, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), ...completionStableTableType(table.table_type), })); } function completionStableTableType(tableType: string | null | undefined): Partial> { if (!tableType || !isTdengineStableTableType(tableType)) return {}; return { tableType: tableType.trim() }; } function sameSidebarObjectName(left: string | undefined, right: string | undefined): boolean { return (left || "").toLowerCase() === (right || "").toLowerCase(); } function treeNodeObjectIdentity(node: TreeNode): string { return `${node.type}\0${node.schema || ""}\0${node.label}`; } function mergeLocatedTreeChildren(parent: TreeNode, currentChildren: TreeNode[], pageChildren: TreeNode[], connectionId: string, database: string): TreeNode[] { const tableChildren = pageChildren.filter((child) => child.type === "table"); const nonTableChildren = pageChildren.filter((child) => child.type !== "table"); let merged = tableChildren.length ? mergeTableTreePageChildren(currentChildren, tableChildren, connectionId, database) : [...currentChildren]; const existing = new Set(merged.map(treeNodeObjectIdentity)); for (const child of nonTableChildren) { const key = treeNodeObjectIdentity(child); if (existing.has(key)) continue; merged.push(child); existing.add(key); } const config = parent.connectionId ? getConfig(parent.connectionId) : undefined; return sortSidebarTreeChildrenForParent( parent, sortDatabaseObjectsByName(merged, (node) => node.label), config?.db_type, ); } function findTreeNodes(nodes: TreeNode[], predicate: (node: TreeNode) => boolean): TreeNode[] { const matches: TreeNode[] = []; for (const node of nodes) { if (predicate(node)) matches.push(node); if (node.children) matches.push(...findTreeNodes(node.children, predicate)); const hiddenOnlyChildren = node.hiddenChildren?.filter((child) => !(node.children || []).includes(child)); if (hiddenOnlyChildren?.length) matches.push(...findTreeNodes(hiddenOnlyChildren, predicate)); } return matches; } async function loadPagedTableGroupChildren(options: { node: TreeNode; parentNodeId: string; querySchema: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; offset: number; pageSize: number; searchFilter?: string; force?: boolean; }): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> { if (!options.node.connectionId || options.node.database == null) { return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset }; } const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined; const tableNameFilter = activeTableNameFilterForScope({ connectionId: options.node.connectionId, database: options.node.database, schema: options.node.schema, nodeKind: options.node.type, catalog: options.node.catalog, }); const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1; const fetchOffset = searchFilter ? undefined : options.offset; const tables = await loadCachedMetadataListPage( metadataListCacheScope({ kind: "table-list-page", connectionId: options.node.connectionId, database: options.node.database, schema: options.querySchema, nodeKind: options.node.type, objectTypes: options.objectTypes, searchFilter, limit: fetchLimit, offset: fetchOffset, sidebarDisplayMode: "grouped", extra: tableNameFilterMetadataExtra(tableNameFilter), }), () => listTablesWithOptionalTableNameFilter(options.node.connectionId!, options.node.database!, options.querySchema, searchFilter, fetchLimit, fetchOffset, options.objectTypes, options.node.catalog, tableNameFilter), { force: options.force }, ); const hasMore = searchFilter ? false : tables.length > options.pageSize; const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables; indexCompletionTables(options.node.connectionId, options.node.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema)); const objects = mergeTableInfosIntoObjects([], pageTables, options.effectiveSchema); const children = objectGroupChildrenFromObjects({ node: options.node, parentNodeId: options.parentNodeId, effectiveSchema: options.effectiveSchema, objectTypes: options.objectTypes, objects, }); const lastTable = pageTables[pageTables.length - 1]; return { children, objectCount: children.length, hasMore, nextOffset: options.offset + pageTables.length, loadMoreParent: lastTable?.parent_name ? { schema: lastTable.parent_schema, name: lastTable.parent_name } : undefined, }; } async function loadPagedObjectGroupChildren(options: { node: TreeNode; parentNodeId: string; querySchema: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; offset: number; pageSize: number; searchFilter?: string; force?: boolean; }): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number }> { if (!options.node.connectionId || options.node.database == null) { return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset }; } const searchFilter = options.searchFilter || undefined; const fetchLimit = searchFilter ? undefined : options.pageSize + 1; const fetchOffset = searchFilter ? undefined : options.offset; const objects = await loadCachedMetadataListPage( metadataListCacheScope({ kind: "object-list-page", connectionId: options.node.connectionId, database: options.node.database, schema: options.querySchema, nodeKind: options.node.type, objectTypes: options.objectTypes, searchFilter, limit: fetchLimit, offset: fetchOffset, sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay, }), () => api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset), { force: options.force }, ); const hasMore = searchFilter ? false : objects.length > options.pageSize; const pageObjects = hasMore ? objects.slice(0, options.pageSize) : objects; const children = objectGroupChildrenFromObjects({ node: options.node, parentNodeId: options.parentNodeId, effectiveSchema: options.effectiveSchema, objectTypes: options.objectTypes, objects: pageObjects, }); return { children, objectCount: children.length, hasMore, nextOffset: options.offset + pageObjects.length, }; } async function loadPagedSimpleTableChildren(options: { nodeId: string; connectionId: string; database: string; querySchema: string; effectiveSchema?: string; nonTableObjectTypes: DatabaseObjectTreeKind[]; offset: number; pageSize: number; searchFilter?: string; force?: boolean; }): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> { const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined; const tableNameFilter = activeTableNameFilterForScope({ connectionId: options.connectionId, database: options.database, schema: options.effectiveSchema ?? options.querySchema, nodeKind: "simple-tables", }); const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1; const fetchOffset = searchFilter ? undefined : options.offset; const tables = await loadCachedMetadataListPage( metadataListCacheScope({ kind: "table-list-page", connectionId: options.connectionId, database: options.database, schema: options.querySchema, nodeKind: "simple-tables", searchFilter, limit: fetchLimit, offset: fetchOffset, sidebarDisplayMode: "simple", extra: tableNameFilterMetadataExtra(tableNameFilter), }), () => listTablesWithOptionalTableNameFilter(options.connectionId, options.database, options.querySchema, searchFilter, fetchLimit, fetchOffset, undefined, undefined, tableNameFilter), { force: options.force }, ); const hasMore = searchFilter ? false : tables.length > options.pageSize; const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables; indexCompletionTables(options.connectionId, options.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema)); const children = buildTableTreeNodes({ nodeId: options.nodeId, connectionId: options.connectionId, database: options.database, schema: options.effectiveSchema, tables: pageTables, }); const lastTable = pageTables[pageTables.length - 1]; return { children, objectCount: children.length, hasMore, nextOffset: options.offset + pageTables.length, loadMoreParent: lastTable?.parent_name ? { schema: lastTable.parent_schema, name: lastTable.parent_name } : undefined, }; } async function loadSimpleSupplementalObjectChildren(options: { node: TreeNode; nodeId: string; connectionId: string; database: string; querySchema: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; cacheKey: string; loadOptions?: LoadTreeOptions; load: TreeNodeLoadHandle; }) { if (options.objectTypes.length === 0) return; const searchFilter = activeTreeLoadSearchFilter(options.loadOptions); if (searchFilter) return; try { const objects = await loadCachedMetadataListPage( metadataListCacheScope({ kind: "object-list-page", connectionId: options.connectionId, database: options.database, schema: options.querySchema, nodeKind: "simple-supplemental", objectTypes: options.objectTypes, sidebarDisplayMode: "simple", }), () => api.listObjects(options.connectionId, options.database, options.querySchema, options.objectTypes), { force: options.loadOptions?.force }, ); const supplementalObjects = filterSimpleSidebarSupplementalObjects(objects); if (supplementalObjects.length === 0) return; let supplementalChildren = buildSimpleObjectTreeNodes({ nodeId: options.nodeId, connectionId: options.connectionId, database: options.database, schema: options.effectiveSchema, objects: supplementalObjects, }); if (supportsPackageMemberExpansion(effectiveDatabaseTypeForConnection(getConfig(options.connectionId)))) { supplementalChildren = markPackageNodesExpandable(supplementalChildren); } if (supplementalChildren.length === 0) return; if (isTreeLoadSearchChanged(searchFilter, options.loadOptions)) return; const targetNode = treeNodeLoadTarget(options.load); if (!targetNode) return; const loadMoreNodes = (targetNode.children || []).filter((child) => child.type === "load-more"); const currentChildren = withoutLoadMoreNodes(targetNode.children); const mergedChildren = mergeLocatedTreeChildren(targetNode, currentChildren, supplementalChildren, options.connectionId, options.database); const nextChildren = [...mergedChildren, ...loadMoreNodes]; setChildren(targetNode, nextChildren); await savePersistedTreeChildren(options.cacheKey, nextChildren); } catch (error) { // Some drivers only expose table metadata; keep the already-rendered table tree usable. console.debug("[DBX][metadata:simple-supplemental:error]", { connectionId: options.connectionId, database: options.database, schema: options.effectiveSchema, error, }); } } function refreshStaleTreeNode(node: TreeNode) { const searchFilter = sidebarSearchQuery.value || ""; if (searchFilter) return; const liveNode = treeNodeInSidebarTree(node); if (!liveNode) return; if (staleTreeRefreshIds.has(liveNode.id)) return; staleTreeRefreshIds.add(liveNode.id); const expandedIds = collectExpandedNodeIds([liveNode]); clearLoadedChildrenCache(liveNode.id); const refreshOptions = { force: true, expectedSidebarSearchQuery: searchFilter }; void loadTreeNodeChildren(liveNode, refreshOptions) .then(() => { if ((sidebarSearchQuery.value || "") !== searchFilter) return; return restoreExpandedChildren(liveNode, expandedIds, refreshOptions); }) .finally(() => staleTreeRefreshIds.delete(liveNode.id)); } async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string, load: TreeNodeLoadHandle): Promise { const trace = createMetadataLoadTrace({ kind: "persisted-tree-cache", connectionId: node.connectionId, database: node.database, schema: node.schema, nodeKind: node.type, extra: { cacheKey }, }); const payload = await api.loadSchemaCache(cacheKey).catch(() => null); const decoded = decodeSchemaTreeCache(payload); if (!decoded) { logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss" }); return { hit: false, isStale: false }; } const config = node.connectionId ? getConfig(node.connectionId) : undefined; const cachedChildren = normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children)); const childrenWithLinkedServers = node.type === "connection" && node.connectionId ? ensureSqlServerLinkedRootNode(node.connectionId, cachedChildren, config) : cachedChildren; if (node.type === "connection" && !hasConnectionMetadataChildren(childrenWithLinkedServers)) { logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss", resultCount: 0 }); return { hit: false, isStale: false }; } // Gate cache apply on the same per-node generation as network apply — connection // revision alone is not enough when a newer force-load supersedes this handle. const targetNode = treeNodeLoadTarget(load); if (!targetNode) { logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss" }); return { hit: false, isStale: false }; } const normalizedChildren = sortSidebarTreeChildrenForParent(targetNode, childrenWithLinkedServers, config?.db_type); setChildren(targetNode, targetNode.type === "connection" && targetNode.connectionId ? withSavedSqlRoot(targetNode.connectionId, normalizedChildren, targetNode) : normalizedChildren); targetNode.isExpanded = true; logMetadataLoadTrace(metadataTraceLogger, trace, "cache-hit", { cacheStatus: decoded.isStale ? "stale" : "hit", resultCount: normalizedChildren.length, stale: decoded.isStale, }); return { hit: true, isStale: decoded.isStale }; } async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) { await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined); } function sidebarTableSearchTreeCacheKey(parent: TreeNode): string | null { if (!parent.connectionId || !parent.database) return null; if (parent.type === "group-tables") return objectGroupCacheKey(parent); if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return null; const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7"); return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", cacheVersion); } function sidebarTableSearchIndexCacheKey(parent: TreeNode): string | null { const treeCacheKey = sidebarTableSearchTreeCacheKey(parent); return treeCacheKey ? `${treeCacheKey}:table-search-index-v1` : null; } async function loadSidebarTableSearchIndex(parentNodeId: string): Promise { const parent = findNode(treeNodes.value, parentNodeId); if (!parent) return null; const cacheKey = sidebarTableSearchIndexCacheKey(parent); if (!cacheKey) return null; const decoded = decodeSchemaTreeCache(await api.loadSchemaCache(cacheKey).catch(() => null)); const index = decoded?.tableSearchIndex; if (!index) return null; return index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType })); } async function refreshSidebarTableSearchIndex(parentNodeId: string): Promise { const parent = findNode(treeNodes.value, parentNodeId); if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return []; const cacheKey = sidebarTableSearchIndexCacheKey(parent); if (!cacheKey) return []; await ensureConnected(parent.connectionId); const config = getConfig(parent.connectionId); const querySchema = connectionObjectTreeQuerySchema(config, parent.database, parent.schema); const objectTypes = parent.type === "group-tables" ? (objectTypesForGroupNode(parent.type) ?? undefined) : undefined; const pageSize = sidebarObjectGroupPageSize(); const entries: TableInfo[] = []; for (let offset = 0; ; offset += pageSize) { const page = await listTablesWithOptionalTableNameFilter(parent.connectionId, parent.database, querySchema, undefined, pageSize, offset, objectTypes, parent.catalog); entries.push(...page); if (page.length < pageSize) break; } const deduped = [...new Map(entries.map((entry) => [`${entry.table_type}\0${entry.name}`, entry])).values()]; const tableSearchIndex = { complete: true as const, indexedAt: new Date().toISOString(), entries: deduped.map((entry) => ({ name: entry.name, tableType: entry.table_type })) }; await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache([], Date.now(), tableSearchIndex)); return deduped; } async function savePersistedConnectionTreeChildren(cacheKey: string, children: TreeNode[]) { const metadataChildren = connectionMetadataChildren(children); if (metadataChildren.length === 0) return; await savePersistedTreeChildren(cacheKey, metadataChildren); } function connectionRootCacheKey(connectionId: string, config: ConnectionConfig | undefined): string | null { if (!config || connectionIsDorisFamilyCatalogCapable(config)) return null; if (config.db_type === "duckdb") return schemaCacheKey(connectionId, "duckdb-root"); if (connectionUsesVisibleSchemaFilter(config)) { return schemaCacheKey(connectionId, config.database || "", config.db_type === "oracle" ? "schemas-v2" : "schemas", config.show_system_schemas === true ? "show-system" : "hide-system"); } return schemaCacheKey(connectionId, "databases-v2"); } async function hydrateTreeNodeFromCache(node: TreeNode | null, cacheKey: string | null): Promise { if (!node || !cacheKey || loadedTreeNodeChildrenIds.value.has(node.id)) return false; const load = beginTreeNodeLoad(node); try { return (await loadPersistedTreeChildren(node, cacheKey, load)).hit; } finally { finishTreeNodeLoad(load); } } async function hydrateConnectionRootFromCache(connectionId: string, config: ConnectionConfig | undefined): Promise { return hydrateTreeNodeFromCache(findConnectionNode(connectionId), connectionRootCacheKey(connectionId, config)); } function isTreeNodeLoadedChildrenUsable(node: TreeNode): boolean { const sidebarObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay; if (!treeNodeLoadedChildrenContentPresent(node, sidebarObjectDisplay)) return false; if (simpleModeEmptyShellNeedsConfirmedLoad(node, sidebarObjectDisplay) && !confirmedEmptyTreeNodeIds.value.has(node.id)) { return false; } return true; } function canUseLoadedTreeNodeToggle(node: TreeNode): boolean { return loadedTreeNodeChildrenIds.value.has(node.id) && isTreeNodeLoadedChildrenUsable(node); } function useCachedChildren(node: TreeNode, options: LoadTreeOptions | undefined, load: TreeNodeLoadHandle): boolean { if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) return false; if (!load.isCurrent()) return false; if (node.type === "connection" && node.connectionId) { if (!hasConnectionMetadataChildren(node.children)) { clearLoadedChildrenCache(node.id); return false; } const normalizedChildren = sortSidebarTreeChildrenForParent(node, withSavedSqlRoot(node.connectionId, node.children || [], node), getConfig(node.connectionId)?.db_type); const liveNode = treeNodeLoadTarget(load); if (!liveNode) return false; setChildren(liveNode, normalizedChildren); liveNode.isExpanded = true; } else if (!isTreeNodeLoadedChildrenUsable(node)) { clearLoadedChildrenCache(node.id); return false; } const liveNode = treeNodeLoadTarget(load); if (!liveNode) return false; liveNode.isExpanded = true; return true; } function isSidebarSearchQueryChanged(options?: LoadTreeOptions) { return options?.expectedSidebarSearchQuery !== undefined && (sidebarSearchQuery.value || "") !== options.expectedSidebarSearchQuery; } function isSidebarTableSearchQueryChanged(options?: LoadTreeOptions) { if (!options?.sidebarTableSearchParentId || options.expectedSidebarTableSearchQuery === undefined) return false; return (sidebarTableSearchQueries.value[options.sidebarTableSearchParentId]?.trim() || "") !== options.expectedSidebarTableSearchQuery; } function activeTreeLoadSearchFilter(options?: LoadTreeOptions): string { return (options?.searchFilter ?? sidebarSearchQuery.value) || ""; } function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean { if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options); return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options); } function isTreeNodeChildrenLoaded(nodeId: string): boolean { return loadedTreeNodeChildrenIds.value.has(nodeId); } // Collapsing a node only hides it — its loaded children stay in memory, so a // long browsing session accumulates every schema the user ever expanded and // the webview creeps upward. When a *large* subtree is collapsed we drop its // children so the memory is reclaimed; re-expanding reloads them (fast, from // the schema cache). Small subtrees are kept so routine expand/collapse stays // instant and never triggers a reload. const RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS = 400; function countTreeNodeDescendants(node: TreeNode, cap: number): number { let count = 0; const stack: TreeNode[] = [...(node.children ?? [])]; while (stack.length) { const current = stack.pop()!; count += 1; if (count >= cap) return count; if (current.children?.length) stack.push(...current.children); } return count; } function forgetLoadedChildrenIdsForSubtree(node: TreeNode) { loadedTreeNodeChildrenIds.value.delete(node.id); confirmedEmptyTreeNodeIds.value.delete(node.id); for (const child of node.children ?? []) { forgetLoadedChildrenIdsForSubtree(child); } } // Returns true when the collapsed node's children were released. Caller should // have already set node.isExpanded = false. Re-expanding reloads on demand // because the node id is removed from loadedTreeNodeChildrenIds. function releaseCollapsedTreeNodeChildren(nodeId: string): boolean { const node = findNode(treeNodes.value, nodeId); if (!node?.children?.length) return false; if (countTreeNodeDescendants(node, RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) < RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) { return false; } forgetLoadedChildrenIdsForSubtree(node); node.children = []; return true; } function treeNodeInSidebarTree(node: TreeNode): TreeNode | null { return findNode(treeNodes.value, node.id); } function beginTreeNodeLoad(node: TreeNode): TreeNodeLoadHandle { return treeNodeLoads.begin(node); } function reclaimTreeNodeLoad(load: TreeNodeLoadHandle, node: TreeNode): TreeNodeLoadHandle { return load.reclaim(treeNodeInSidebarTree(node) ?? node); } function treeNodeLoadTarget(load: TreeNodeLoadHandle): TreeNode | null { return load.targetNode( (nodeId) => findNode(treeNodes.value, nodeId), (connectionId) => connectedIds.value.has(connectionId), ) as TreeNode | null; } function finishTreeNodeLoad(load: TreeNodeLoadHandle) { load.finish((nodeId) => findNode(treeNodes.value, nodeId)); } /** Apply to a related node only while this load handle is still current. */ function treeNodeLoadRelatedTarget(load: TreeNodeLoadHandle, related: TreeNode): TreeNode | null { if (!load.isCurrent()) return null; const current = treeNodeInSidebarTree(related); if (!current) return null; if (current.connectionId && !connectedIds.value.has(current.connectionId)) return null; return current; } function clearLoadedChildrenCache(prefix: string, options?: { deletePersisted?: boolean }) { for (const id of loadedTreeNodeChildrenIds.value) { if (id === prefix || id.startsWith(`${prefix}:`)) { loadedTreeNodeChildrenIds.value.delete(id); } } for (const id of confirmedEmptyTreeNodeIds.value) { if (id === prefix || id.startsWith(`${prefix}:`)) { confirmedEmptyTreeNodeIds.value.delete(id); } } invalidateMetadataCachesByTreePrefix(prefix); if (options?.deletePersisted === false) return; const rawPrefix = `${prefix}:`; const encodedPrefix = `${schemaCacheKey(prefix)}:`; if (rawPrefix === encodedPrefix) { api.deleteSchemaCachePrefix(rawPrefix).catch(() => undefined); } else { Promise.all([api.deleteSchemaCachePrefix(rawPrefix), api.deleteSchemaCachePrefix(encodedPrefix)]).catch(() => undefined); } } function schemaCachePrefixForNode(node: TreeNode): string | null { return treeNodeSchemaCachePrefix(node); } async function clearPersistedTreeCacheForNode(node: TreeNode) { const prefix = schemaCachePrefixForNode(node); if (!prefix) return; await api.deleteSchemaCachePrefix(prefix).catch(() => undefined); } function findParentNode(nodes: TreeNode[], id: string, parent: TreeNode | null = null): TreeNode | null { for (const node of nodes) { if (node.id === id) return parent; if (node.children) { const found = findParentNode(node.children, id, node); if (found) return found; } } return null; } function toggleTreeNodePin(node: TreeNode) { const pinKey = treeNodePinKey(node); const wasPinned = pinnedTreeNodeIds.value.has(pinKey) || pinnedTreeNodeIds.value.has(node.id); // Remove the legacy bare id as part of every toggle so old ambiguous pins // cannot continue matching objects in a different database. Newly pinned // nodes append to the persisted order, placing them last in their sibling // pin section until the user explicitly reorders them. const next = pinnedTreeNodeOrder.value.filter((id) => id !== node.id && id !== pinKey); if (!wasPinned) next.push(pinKey); setPinnedTreeNodeOrder(next); persistPinnedTreeNodeIds(); // Pinning is infrequent; synchronizing the loaded tree here also clears any // stale flags created by legacy unscoped ids without rebuilding metadata. syncPinnedTreeState(treeNodes.value); } function findPinnedTreeNodeLocation(nodes: TreeNode[], pinKey: string): { node: TreeNode; siblings: TreeNode[] } | null { for (const node of nodes) { if (treeNodePinKey(node) === pinKey) return { node, siblings: nodes }; if (node.children) { const found = findPinnedTreeNodeLocation(node.children, pinKey); if (found) return found; } if (node.hiddenChildren) { const found = findPinnedTreeNodeLocation(node.hiddenChildren, pinKey); if (found) return found; } } return null; } function collectPinnedTreeNodeReorderTargets(draggedKey: string): Set { const dragged = findPinnedTreeNodeLocation(treeNodes.value, draggedKey); if (!dragged || !isTreeNodePinned(dragged.node) || isFixedPriorityTreeNode(dragged.node)) return new Set(); const targets = new Set(); for (const sibling of dragged.siblings) { const siblingKey = treeNodePinKey(sibling); if (siblingKey === draggedKey || !isTreeNodePinned(sibling) || isFixedPriorityTreeNode(sibling)) continue; targets.add(siblingKey); } return targets; } const activePinnedTreeNodeReorderTargets = computed(() => { const draggedKey = activePinnedTreeNodeReorderKey.value; return draggedKey ? collectPinnedTreeNodeReorderTargets(draggedKey) : new Set(); }); function beginPinnedTreeNodeReorder(draggedKey: string) { activePinnedTreeNodeReorderKey.value = draggedKey || null; } function endPinnedTreeNodeReorder() { activePinnedTreeNodeReorderKey.value = null; } function isPinnedTreeNodeReorderTarget(targetKey: string): boolean { return !!targetKey && targetKey !== activePinnedTreeNodeReorderKey.value && activePinnedTreeNodeReorderTargets.value.has(targetKey); } function canReorderPinnedTreeNodes(draggedKey: string, targetKey: string): boolean { if (!draggedKey || !targetKey || draggedKey === targetKey) return false; if (activePinnedTreeNodeReorderKey.value === draggedKey) return activePinnedTreeNodeReorderTargets.value.has(targetKey); return collectPinnedTreeNodeReorderTargets(draggedKey).has(targetKey); } function reorderPinnedTreeNodes(draggedKey: string, targetKey: string, position: DropPosition): boolean { if (position === "inside" || !canReorderPinnedTreeNodes(draggedKey, targetKey)) return false; const next = reorderPinnedTreeNodeOrder(pinnedTreeNodeOrder.value, draggedKey, targetKey, position); if (next.length === pinnedTreeNodeOrder.value.length && next.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false; setPinnedTreeNodeOrder(next); syncPinnedTreeState(treeNodes.value); persistPinnedTreeNodeIds(); return true; } async function addConnection(config: ConnectionConfig, targetGroupId?: string | null) { const normalized = normalizeConnection(config); await persistTimeoutInheritance(normalized.id, normalized.connect_timeout_inherit === true, normalized.query_timeout_inherit === true); const existing = connections.value.findIndex((c) => c.id === normalized.id); const nextConnections = [...connections.value]; if (existing >= 0) { nextConnections[existing] = normalized; } else { nextConnections.push(normalized); const groupId = targetGroupId !== undefined ? targetGroupId : newConnectionGroupId.value; sidebarLayout.value = appendConnectionToLayout(sidebarLayout.value, normalized.id, groupId); } await persistConnections(nextConnections); connections.value = nextConnections; syncTimeoutInheritanceBackup(); rebuildTreeNodes(); persistSidebarLayoutDebounced(); stopCreatingConnectionInGroup(); } function copyConnectionsToTreeClipboard(connectionIds: Iterable): number { const seen = new Set(); const entries: TreeClipboardConnectionEntry[] = []; for (const connectionId of connectionIds) { if (seen.has(connectionId)) continue; seen.add(connectionId); const config = getConfig(connectionId); if (!config) continue; entries.push({ config: { ...config }, sourceGroupId: findConnectionLocation(sidebarLayout.value, connectionId)?.groupId ?? null, }); } if (!entries.length) return 0; treeClipboard.value = { kind: "connection-copy", connections: entries }; return entries.length; } async function pasteConnectionClipboard(targetGroupId?: string | null): Promise { const clipboard = treeClipboard.value; if (clipboard?.kind !== "connection-copy" || clipboard.connections.length === 0) return 0; let pastedCount = 0; for (const entry of clipboard.connections) { await addConnection( { ...entry.config, id: uuid(), name: `${entry.config.name} (Copy)`, }, targetGroupId === undefined ? entry.sourceGroupId : targetGroupId, ); pastedCount += 1; } return pastedCount; } function invalidateCompletionCache(connectionId: string, database?: string) { invalidateMetadataCaches({ connectionId, database }); if (database == null) delete completionDatabasesCache.value[connectionId]; const cachePrefix = database == null ? `${connectionId}:` : `${connectionId}:${database}:`; const exactCacheKey = database == null ? null : `${connectionId}:${database}`; for (const key of Object.keys(completionTablesCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionTablesCache.value[key]; } for (const key of Object.keys(completionObjectsCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionObjectsCache.value[key]; } for (const key of Object.keys(completionColumnsCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionColumnsCache.value[key]; } for (const key of Object.keys(completionForeignKeysCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionForeignKeysCache.value[key]; } for (const key of Object.keys(schemaListCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete schemaListCache.value[key]; } for (const key of Object.keys(sqlServerCompletionContextCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete sqlServerCompletionContextCache.value[key]; } for (const key of Object.keys(elasticsearchCompletionIndicesCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key]; } for (const key of Object.keys(redisCompletionKeysCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key]; } for (const key of Object.keys(mongoCompletionCollectionsCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionCollectionsCache.value[key]; } for (const key of Object.keys(mongoCompletionFieldsCache.value)) { if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionFieldsCache.value[key]; } for (const key of completionTableIndex.keys()) { if (key.startsWith(cachePrefix)) completionTableIndex.delete(key); } for (const key of completionObjectIndex.keys()) { if (key.startsWith(cachePrefix)) completionObjectIndex.delete(key); } for (const key of completionColumnIndex.keys()) { if (key.startsWith(cachePrefix)) completionColumnIndex.delete(key); } for (const key of completionForeignKeyIndex.keys()) { if (key.startsWith(cachePrefix)) completionForeignKeyIndex.delete(key); } for (const key of completionInFlight.keys()) { if (key.startsWith(cachePrefix)) completionInFlight.delete(key); } } async function removeConnections(ids: Iterable) { const connectionIds = [...new Set(ids)].filter((id) => connections.value.some((c) => c.id === id)); if (!connectionIds.length) return; const removedIds = new Set(connectionIds); const nextConnections = connections.value.filter((c) => !removedIds.has(c.id)); await persistConnections(nextConnections); await persistTimeoutInheritanceIds( settingsStore.editorSettings.connectTimeoutInheritConnectionIds.filter((id) => !removedIds.has(id)), settingsStore.editorSettings.queryTimeoutInheritConnectionIds.filter((id) => !removedIds.has(id)), ); connections.value = nextConnections; syncTimeoutInheritanceBackup(); let nextPinnedOrder = pinnedTreeNodeOrder.value; for (const id of removedIds) { const prefix = `${id}:`; nextPinnedOrder = nextPinnedOrder.filter((pinId) => pinId !== id && !pinId.startsWith(prefix)); } setPinnedTreeNodeOrder(nextPinnedOrder); persistPinnedTreeNodeIds(); removeSidebarTableNameFiltersForConnections(removedIds); for (const id of removedIds) { clearConnectionError(id); connectionErrorRevisions.delete(id); connectedIds.value.delete(id); clearPrimaryVisibleObjectNames(id); clearConnectionIdentifierQuote(id); clearConnectionHealthCheck(id); sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id); } rebuildTreeNodes(); persistSidebarLayoutDebounced(); if (activeConnectionId.value && removedIds.has(activeConnectionId.value)) { activeConnectionId.value = null; } selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => !removedIds.has(id)); if (selectedTreeNodeId.value && removedIds.has(selectedTreeNodeId.value)) selectedTreeNodeId.value = null; if (treeSelectionAnchorId.value && removedIds.has(treeSelectionAnchorId.value)) treeSelectionAnchorId.value = null; for (const id of removedIds) { invalidateCompletionCache(id); void invalidateObjectDdlCache({ connectionId: id }); clearLoadedChildrenCache(id); void deleteTabResultSnapshotsForOwner(id); } } async function removeConnection(id: string) { await removeConnections([id]); } async function updateConnection(config: ConnectionConfig) { config = normalizeConnection(config); const idx = connections.value.findIndex((c) => c.id === config.id); if (idx < 0) return; const runtimeConfigChanged = connectionConfigFingerprint(connections.value[idx]) !== connectionConfigFingerprint(config); const nextConnections = [...connections.value]; nextConnections[idx] = config; await persistTimeoutInheritance(config.id, config.connect_timeout_inherit === true, config.query_timeout_inherit === true); await persistConnections(nextConnections); connections.value = nextConnections; syncTimeoutInheritanceBackup(); rebuildTreeNodes(); if (!runtimeConfigChanged) return; clearPrimaryVisibleObjectNames(config.id); connectedIds.value.delete(config.id); clearConnectionIdentifierQuote(config.id); clearConnectionHealthCheck(config.id); invalidateCompletionCache(config.id); void invalidateObjectDdlCache({ connectionId: config.id }); clearLoadedChildrenCache(config.id); const node = findConnectionNode(config.id); if (node?.isExpanded) { await reloadConnectionDatabaseChildren(config.id); } } async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise { const normalized = normalizeDatabaseConnectionInfo(databaseInfo); if (!normalized) return; const current = connections.value.find((connection) => connection.id === connectionId); if (!current) return; if (expectedConfigFingerprint && connectionConfigFingerprint(current) !== expectedConfigFingerprint) return; if (JSON.stringify(current.database_info) === JSON.stringify(normalized)) return; await api.saveConnectionDatabaseInfo(connectionId, normalized); const index = connections.value.findIndex((connection) => connection.id === connectionId); if (index < 0) return; if (expectedConfigFingerprint && connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) return; const nextConnections = [...connections.value]; nextConnections[index] = { ...nextConnections[index], database_info: normalized }; connections.value = nextConnections; // Database info is reactive connection metadata, not tree structure. Keep // navigator node identities stable so an in-flight first expansion can // still apply its loaded children after this background refresh completes. } async function refreshConnectedDatabaseInfo(connectionId: string, config: ConnectionConfig): Promise { const expectedConfigFingerprint = connectionConfigFingerprint(config); try { const detected = await api.connectionDatabaseInfo(connectionId); const normalized = normalizeDatabaseConnectionInfo(detected, configuredDatabaseProductName(config), config.database); if (normalized) await updateConnectionDatabaseInfo(connectionId, normalized, expectedConfigFingerprint); } catch { // Database metadata is optional and must not turn a successful connection into a failure. } } async function syncMongoLegacyDriverFallback(connectionId: string, previousConfig: ConnectionConfig) { if (previousConfig.db_type !== "mongodb" || isMongoLegacyDriverProfile(previousConfig.driver_profile)) { return; } const expectedConfigFingerprint = connectionConfigFingerprint(previousConfig); const current = connections.value.find((connection) => connection.id === connectionId); if (!current || connectionConfigFingerprint(current) !== expectedConfigFingerprint) return; const savedConnections = await api.loadConnections().catch(() => null); const savedConfig = savedConnections?.map((connection) => normalizeConnection(connection)).find((connection) => connection.id === connectionId && connection.driver_profile === MONGO_LEGACY_DRIVER_PROFILE); if (!savedConfig) return; const savedOriginalIdentity = { ...savedConfig, driver_profile: previousConfig.driver_profile, driver_label: previousConfig.driver_label, }; if (connectionConfigFingerprint(savedOriginalIdentity) !== expectedConfigFingerprint) return; const idx = connections.value.findIndex((connection) => connection.id === connectionId); if (idx < 0 || connectionConfigFingerprint(connections.value[idx]) !== expectedConfigFingerprint) return; const nextConnections = [...connections.value]; nextConnections[idx] = { ...nextConnections[idx], driver_profile: MONGO_LEGACY_DRIVER_PROFILE, driver_label: savedConfig.driver_label || MONGO_LEGACY_DRIVER_LABEL, }; connections.value = nextConnections; rebuildTreeNodes(); } async function ensureSqlServerLegacyCompatibilityComponentInstalled(config: ConnectionConfig) { if (!requiresSqlServerLegacyCompatibilityComponent(config)) return; if (await api.isAgentInstalled(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY)) return; await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY); } async function setDefaultDatabase(connectionId: string, database: string) { const config = getConfig(connectionId); if (config?.db_type === "cloudflare-d1") return; if (!config || config.database === database) return; await updateConnection({ ...config, database, }); } async function clearDefaultDatabase(connectionId: string) { const config = getConfig(connectionId); if (config?.db_type === "cloudflare-d1") return; if (!config || !config.database) return; await updateConnection({ ...config, database: undefined, }); } function isDefaultDatabase(connectionId: string, database: string): boolean { const config = getConfig(connectionId); if (config?.db_type === "cloudflare-d1") return database === "main"; return config?.database === database && database !== ""; } async function setDefaultSchema(connectionId: string, schema: string) { const config = getConfig(connectionId); const defaultSchema = schema.trim(); if (!config || !defaultSchema || config.default_schema === defaultSchema) return; await updateConnection({ ...config, default_schema: defaultSchema, }); } async function clearDefaultSchema(connectionId: string) { const config = getConfig(connectionId); if (!config?.default_schema) return; await updateConnection({ ...config, default_schema: undefined, }); } function isDefaultSchema(connectionId: string, schema: string): boolean { return getConfig(connectionId)?.default_schema === schema && schema !== ""; } function getRedisDatabaseAlias(connectionId: string, database: string | number): string | undefined { return redisDatabaseAlias(getConfig(connectionId)?.redis_database_aliases, database); } async function setRedisDatabaseAlias(connectionId: string, database: string | number, alias?: string) { const index = typeof database === "number" ? database : Number(database); const configIndex = connections.value.findIndex((connection) => connection.id === connectionId); const config = connections.value[configIndex]; if (!config || config.db_type !== "redis" || !Number.isInteger(index) || index < 0) return; const key = String(index); const aliases = { ...(config.redis_database_aliases || {}) }; const normalizedAlias = alias?.trim() || ""; if (normalizedAlias) aliases[key] = normalizedAlias; else delete aliases[key]; const redisDatabaseAliases = normalizeRedisDatabaseAliases(aliases); const nextConnections = [...connections.value]; nextConnections[configIndex] = { ...config, redis_database_aliases: redisDatabaseAliases, }; await persistConnections(nextConnections); connections.value = nextConnections; const node = findNode(treeNodes.value, `${connectionId}:db${key}`); if (node?.type === "redis-db") { node.label = redisDatabaseLabel(index, redisDatabaseAliases, node.totalKeyCount); } } async function setVisibleDatabases(connectionId: string, databaseNames: string[]) { const config = getConfig(connectionId); if (!config) return; await updateVisibleDatabasesConfig(connectionId, normalizeVisibleDatabaseSelection(databaseNames, databaseNames)); await reloadConnectionDatabaseChildren(connectionId); } function recordPrimaryVisibleObjectNames(connectionId: string, objectNames: readonly string[]) { const names = [...objectNames]; const existing = primaryVisibleObjectNames.value[connectionId]; if (existing?.length === names.length && existing.every((name, index) => name === names[index])) return; primaryVisibleObjectNames.value = { ...primaryVisibleObjectNames.value, [connectionId]: names }; } function clearPrimaryVisibleObjectNames(connectionId: string) { if (!(connectionId in primaryVisibleObjectNames.value)) return; const next = { ...primaryVisibleObjectNames.value }; delete next[connectionId]; primaryVisibleObjectNames.value = next; } function getSidebarVisibleFilterSummary(connectionId: string) { const config = getConfig(connectionId); if (config?.db_type === "nacos") return nacosVisibleNamespaceSummary(config, primaryVisibleObjectNames.value[connectionId]); return config ? sidebarVisibleFilterSummary(config, primaryVisibleObjectNames.value[connectionId]) : null; } function scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId: string, config: ConnectionConfig | undefined, options?: LoadTreeOptions) { if (options?.force || !config || primaryVisibleObjectNames.value[connectionId] || primaryVisibleObjectRefreshInFlight.has(connectionId)) return; if (!connectionCanConfigureSidebarVisibleDatabases(config.db_type) || !connectionHasConfiguredSidebarVisibleFilter(config)) return; primaryVisibleObjectRefreshInFlight.add(connectionId); void loadDatabases(connectionId, { force: true, connectedOnly: true }) .catch(() => undefined) .finally(() => primaryVisibleObjectRefreshInFlight.delete(connectionId)); } async function clearVisibleDatabases(connectionId: string) { const config = getConfig(connectionId); if (!config || !Array.isArray(config.visible_databases)) return; await updateVisibleDatabasesConfig(connectionId, undefined); await reloadConnectionDatabaseChildren(connectionId); } async function ensureVisibleDatabase(connectionId: string, databaseName: string) { const config = getConfig(connectionId); if (!config) return; const visibleDatabases = appendVisibleDatabaseSelection(config.visible_databases, databaseName); if (visibleDatabases === config.visible_databases) return; await updateVisibleDatabasesConfig(connectionId, visibleDatabases); } async function updateVisibleDatabasesConfig(connectionId: string, visibleDatabases: string[] | undefined) { const idx = connections.value.findIndex((connection) => connection.id === connectionId); if (idx < 0) return; const nextConnections = [...connections.value]; nextConnections[idx] = { ...nextConnections[idx], visible_databases: visibleDatabases, }; await persistConnections(nextConnections); connections.value = nextConnections; invalidateCompletionCache(connectionId); rebuildTreeNodes(); } async function setVisibleSchemas(connectionId: string, database: string, schemaNames: string[]) { const config = getConfig(connectionId); if (!config) return; const key = database || ""; await updateVisibleSchemasConfig(connectionId, key, schemaNames); await reloadSchemaChildren(connectionId, database); } async function clearVisibleSchemas(connectionId: string, database: string) { const config = getConfig(connectionId); if (!config || !config.visible_schemas) return; const key = database || ""; await updateVisibleSchemasConfig(connectionId, key, undefined); await reloadSchemaChildren(connectionId, database); } async function updateVisibleSchemasConfig(connectionId: string, database: string, schemaNames: string[] | undefined) { const idx = connections.value.findIndex((connection) => connection.id === connectionId); if (idx < 0) return; const existing = connections.value[idx].visible_schemas; let nextSchemas: Record | undefined; if (schemaNames) { nextSchemas = { ...existing, [database]: schemaNames }; } else if (existing) { nextSchemas = { ...existing }; delete nextSchemas[database]; if (Object.keys(nextSchemas).length === 0) nextSchemas = undefined; } const nextConnections = [...connections.value]; nextConnections[idx] = { ...nextConnections[idx], visible_schemas: nextSchemas, }; await persistConnections(nextConnections); connections.value = nextConnections; rebuildTreeNodes(); } async function reloadSchemaChildren(connectionId: string, database?: string) { const config = getConfig(connectionId); if (!config) return; const db = database || config.database || ""; clearLoadedChildrenCache(connectionId); clearLoadedChildrenCache(`${connectionId}:${db}`); await loadDatabases(connectionId, { force: true }); // After saving schema filter, force-refresh database node's schema children // to avoid stale children from previously expanded nodes if (db) { const dbNode = findNode(treeNodes.value, `${connectionId}:${db}`); if (dbNode) { await loadTreeNodeChildren(dbNode, { force: true }); } } } async function reloadConnectionDatabaseChildren(connectionId: string) { const config = getConfig(connectionId); if (!config) return; clearLoadedChildrenCache(connectionId); if (config.db_type === "redis") { await loadRedisDatabases(connectionId); } else if (config.db_type === "etcd") { await loadEtcdRoot(connectionId); } else if (config.db_type === "zookeeper") { await loadZooKeeperRoot(connectionId); } else if (config.db_type === "consul") { await loadConsulRoot(connectionId); } else if (config.db_type === "mongodb") { await loadMongoDatabases(connectionId); } else if (config.db_type === "elasticsearch" || config.db_type === "easysearch") { // Reload: list indices. await loadElasticsearchIndices(connectionId); } else if (config.db_type === "milvus") { await loadMilvusDatabases(connectionId); } else if (config.db_type === "qdrant" || config.db_type === "weaviate" || config.db_type === "chromadb") { await loadVectorCollections(connectionId); } else if (config.db_type === "mq") { await loadMqTenants(connectionId, { force: true }); } else if (config.db_type === "mqtt") { await loadMqttTopics(connectionId); } else if (config.db_type === "nacos") { await loadNacosNamespaces(connectionId, { force: true }); } else { await loadDatabases(connectionId, { force: true }); } } async function connect(config: ConnectionConfig) { config = normalizeConnection(config); if (getBlockingDisconnectInFlight(config.id)) await waitForBlockingDisconnectInFlight(config.id); const localAttempt = beginLocalConnectionAttempt(config.id); try { await beforeConnectHandler?.(config); if (config.db_type === "sqlserver") { await ensureSqlServerLegacyCompatibilityComponentInstalled(config); } ensureLocalConnectionAttemptActive(config.id, localAttempt); const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id); await syncMongoLegacyDriverFallback(id, config); await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id); activeConnectionId.value = id; connectedIds.value.add(id); void refreshConnectedDatabaseInfo(id, { ...config, id }); await refreshConnectionIdentifierQuote(id, { ...config, id }); if (id !== config.id) markSuccessfulLocalConnectionAttempt(config.id, localAttempt); markSuccessfulLocalConnectionAttempt(id, localAttempt); markConnectionHealthChecked(id); clearConnectionError(config.id); if (id !== config.id) clearConnectionError(id); const existing = findConnectionNode(id); if (existing) { existing.label = config.name; existing.type = "connection"; existing.connectionId = id; existing.comment = config.note || null; existing.children = existing.children || []; } else { treeNodes.value.push({ id, label: config.name, type: "connection", connectionId: id, isExpanded: false, children: [], comment: config.note || null, }); } return id; } catch (e) { if (isCancelledLocalConnectionAttempt(config.id, localAttempt)) { clearConnectionError(config.id); throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE); } if (isCancelledConnectionAttempt(e) || isSupersededConnectionAttempt(e)) { clearConnectionError(config.id); } else { recordConnectionError(config.id, e); } throw e; } finally { finishLocalConnectionAttempt(config.id, localAttempt); } } async function cancelConnecting(connectionId: string): Promise { const localAttempt = getLocalConnectionAttempt(connectionId); if (localAttempt == null) return false; const disconnectRequest = startCancelDisconnectRequest(connectionId, localAttempt); const cancelled = cancelLocalConnectionAttempt(connectionId); if (!cancelled) return false; clearConnectionError(connectionId); connectedIds.value.delete(connectionId); clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); clearConnectionHealthCheck(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; invalidateCompletionCache(connectionId); await disconnectRequest; return true; } async function disconnect(connectionId: string) { const stateRevision = bumpConnectionStateRevision(connectionId); const disconnectRequest = startDisconnectRequest(connectionId); cancelLocalConnectionAttempt(connectionId); const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true; connectedIds.value.delete(connectionId); clearPrimaryVisibleObjectNames(connectionId); clearConnectionIdentifierQuote(connectionId); forgetSuccessfulLocalConnectionAttempt(connectionId); clearConnectionHealthCheck(connectionId); const node = findConnectionNode(connectionId); if (node) { node.isLoading = false; node.isExpanded = false; node.children = []; } clearConnectionRootMetadataLoad(connectionId); // Disconnecting only tears down the live session. Keep the schema snapshot so // reconnecting can render databases and table names before the remote refresh. clearLoadedChildrenCache(connectionId, { deletePersisted: false }); if (activeConnectionId.value === connectionId) { activeConnectionId.value = null; } invalidateCompletionCache(connectionId); invalidateObjectBrowserRowsCache({ connectionId }); const { useQueryStore } = await import("@/stores/queryStore"); const queryStore = useQueryStore(); switch (settingsStore.editorSettings.disconnectTabHandlingMode) { case "close-tabs": queryStore.closeConnectionTabs(connectionId); break; case "keep-tabs-clear-results": queryStore.releaseConnectionTabs(connectionId); break; case "keep-tabs-keep-results": queryStore.rollbackConnectionTransactions(connectionId); break; } await disconnectRequest; if (isCurrentConnectionStateRevision(connectionId, stateRevision)) { clearConnectionError(connectionId); } if (shouldRemoveOneTimeConnection && isCurrentConnectionStateRevision(connectionId, stateRevision)) { await removeConnection(connectionId); } } async function closeDatabaseConnection(connectionId: string, database: string) { await api.closeDatabaseConnection(connectionId, database); const { useQueryStore } = await import("@/stores/queryStore"); const queryStore = useQueryStore(); switch (settingsStore.editorSettings.disconnectTabHandlingMode) { case "close-tabs": queryStore.closeDatabaseTabs(connectionId, database); break; case "keep-tabs-clear-results": queryStore.releaseDatabaseTabs(connectionId, database); break; case "keep-tabs-keep-results": queryStore.rollbackDatabaseTransactions(connectionId, database); break; } const node = findDatabaseTreeNode(treeNodes.value, connectionId, database); if (node) { node.isExpanded = false; node.children = []; clearLoadedChildrenCache(node.id); } invalidateCompletionCache(connectionId, database); invalidateObjectBrowserRowsCache({ connectionId, database }); } async function ensureConnected(connectionId: string, options: { activate?: boolean } = {}) { if (connectedIds.value.has(connectionId)) { if (hasRecentConnectionHealthCheck(connectionId)) return; // Optimistic: verify backend pool is actually healthy try { await withConnectionHealthTimeout(connectionId, api.checkConnectionHealth(connectionId)); markConnectionHealthChecked(connectionId); return; } catch { // Backend pool is dead — remove from connectedIds and reconnect connectedIds.value.delete(connectionId); clearPrimaryVisibleObjectNames(connectionId); clearConnectionHealthCheck(connectionId); if (activeConnectionId.value === connectionId) activeConnectionId.value = null; } } let config = getConfig(connectionId); if (!config) { await initFromDisk(); config = getConfig(connectionId); } if (!config) { const error = new Error("Connection config not found"); recordConnectionError(connectionId, error); throw error; } if (getBlockingDisconnectInFlight(connectionId)) await waitForBlockingDisconnectInFlight(connectionId); const existingConnect = connectInFlight.get(connectionId); if (existingConnect) { await existingConnect; if (options.activate !== false) activeConnectionId.value = connectionId; return; } const localAttempt = beginLocalConnectionAttempt(connectionId); const connectPromise = (async () => { await beforeConnectHandler?.(config); if (config.db_type === "sqlserver") { await ensureSqlServerLegacyCompatibilityComponentInstalled(config); } ensureLocalConnectionAttemptActive(connectionId, localAttempt); const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config); await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id); await syncMongoLegacyDriverFallback(connectionId, config); await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id); connectedIds.value.add(connectionId); void refreshConnectedDatabaseInfo(connectionId, config); await refreshConnectionIdentifierQuote(connectionId, config); markSuccessfulLocalConnectionAttempt(connectionId, localAttempt); markConnectionHealthChecked(connectionId); clearConnectionError(connectionId); })(); connectInFlight.set(connectionId, connectPromise); try { await connectPromise; if (options.activate !== false) activeConnectionId.value = connectionId; } catch (e) { if (isCancelledLocalConnectionAttempt(connectionId, localAttempt)) { clearConnectionError(connectionId); throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE); } if (isCancelledConnectionAttempt(e)) { clearConnectionError(connectionId); throw e; } if (isSupersededConnectionAttempt(e) && connectedIds.value.has(connectionId)) { clearConnectionError(connectionId); return; } recordConnectionError(connectionId, e); clearConnectionNodeLoading(connectionId); throw e; } finally { if (connectInFlight.get(connectionId) === connectPromise) { connectInFlight.delete(connectionId); } finishLocalConnectionAttempt(connectionId, localAttempt); } } function setBeforeConnectHandler(handler: BeforeConnectHandler | null) { beforeConnectHandler = handler; } function sidebarDatabaseStorageRequestKey(connectionId: string, databases: readonly string[]): string { return `${connectionId}\0${[...databases].sort().join("\0")}`; } async function loadSidebarDatabaseStorage(connectionId: string, options?: { force?: boolean }): Promise { if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(connectionId)) return; if (!supportsSidebarDatabaseStorage(getConfig(connectionId))) return; const connectionNode = findConnectionNode(connectionId); const databases = sidebarDatabaseNames(connectionNode?.children); if (!databases.length) return; const requestKey = sidebarDatabaseStorageRequestKey(connectionId, databases); const cached = sidebarDatabaseStorageCache.get(requestKey); if (!options?.force && cached && cached.expiresAt > Date.now()) { applySidebarDatabaseStorage(connectionNode?.children, cached.value); return; } let request = sidebarDatabaseStorageInFlight.get(requestKey); if (!request) { request = api.listDatabaseStorage(connectionId, databases); sidebarDatabaseStorageInFlight.set(requestKey, request); } try { const storage = await request; sidebarDatabaseStorageCache.set(requestKey, { expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS, value: storage, }); const currentNode = findConnectionNode(connectionId); const currentNames = sidebarDatabaseNames(currentNode?.children); if (sidebarDatabaseStorageRequestKey(connectionId, currentNames) === requestKey) { applySidebarDatabaseStorage(currentNode?.children, storage); } } catch (error) { console.debug("[DBX][sidebar-database-storage:unavailable]", { connectionId, error }); } finally { if (sidebarDatabaseStorageInFlight.get(requestKey) === request) { sidebarDatabaseStorageInFlight.delete(requestKey); } } } function sidebarTableStorageRequestKey(scope: SidebarTableStorageScope): string { return `${scope.connectionId}\0${scope.database}\0${scope.schema}`; } async function loadSidebarTableStorage(scope: SidebarTableStorageScope, options?: { force?: boolean }): Promise { if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(scope.connectionId)) return; if (!supportsSidebarTableStorage(getConfig(scope.connectionId))) return; const requestKey = sidebarTableStorageRequestKey(scope); const cached = sidebarTableStorageCache.get(requestKey); if (!options?.force && cached && cached.expiresAt > Date.now()) { applySidebarTableStorage(treeNodes.value, scope, cached.value); return; } let request = options?.force ? undefined : sidebarTableStorageInFlight.get(requestKey); if (!request) { request = api.listObjectStatistics(scope.connectionId, scope.database, scope.schema); sidebarTableStorageInFlight.set(requestKey, request); } try { const statistics = await request; if (sidebarTableStorageInFlight.get(requestKey) !== request) return; sidebarTableStorageCache.set(requestKey, { expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS, value: statistics, }); applySidebarTableStorage(treeNodes.value, scope, statistics); } catch (error) { console.debug("[DBX][sidebar-table-storage:unavailable]", { ...scope, error }); } finally { if (sidebarTableStorageInFlight.get(requestKey) === request) { sidebarTableStorageInFlight.delete(requestKey); } } } const sidebarDatabaseStorageScope = computed(() => { if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return ""; return [...connectedIds.value] .filter((connectionId) => supportsSidebarDatabaseStorage(getConfig(connectionId))) .map((connectionId) => sidebarDatabaseStorageRequestKey(connectionId, sidebarDatabaseNames(findConnectionNode(connectionId)?.children))) .sort() .join("\n"); }); watch( sidebarDatabaseStorageScope, () => { if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return; for (const connectionId of connectedIds.value) { void loadSidebarDatabaseStorage(connectionId); } }, { flush: "post" }, ); async function loadDatabases(connectionId: string, options?: LoadTreeOptions) { const configForScope = getConfig(connectionId); const searchFilter = activeTreeLoadSearchFilter(options); if (!options?.force && !options?.connectedOnly && !searchFilter) { const cacheHit = await hydrateConnectionRootFromCache(connectionId, configForScope); if (cacheHit) { // Render the last known metadata immediately; network validation and refresh // continue in the background so opening a connection never waits on them. void loadDatabases(connectionId, { ...options, force: true }).catch(() => undefined); return; } } return runTreeMetadataLoad( { kind: "connection-databases", connectionId, driverProfile: metadataDriverProfile(configForScope), }, async () => { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { if (options?.connectedOnly) { if (!connectedIds.value.has(connectionId)) return; } else { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); } const config = getConfig(connectionId); if (useCachedChildren(node, options, load)) { scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId, config, options); return; } if (config?.db_type === "duckdb") { const cacheKey = schemaCacheKey(connectionId, "duckdb-root"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); else scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId, config, options); return; } } const [databases, schemas] = await Promise.all([withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"), withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, "main"), "schemas")]); const databaseNames = databases.map((database) => database.name); const visibleNames = filterDatabaseNamesForConnection(databaseNames, config); const visibleNameSet = new Set(visibleNames); const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); const visibleSchemas = visibleNameSet.has("main") ? schemas : []; const children = withSavedSqlRoot(connectionId, buildDuckDbConnectionTreeNodes(connectionId, visibleDatabases, visibleSchemas), node); if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; recordPrimaryVisibleObjectNames(connectionId, databaseNames); setChildren(targetNode, children); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } else if (config && connectionUsesVisibleSchemaFilter(config)) { const schemaFilterConfig = config; const effectiveDb = schemaFilterConfig.database || ""; const showSystemSchemas = schemaFilterConfig.show_system_schemas === true; const cacheKey = schemaCacheKey(connectionId, effectiveDb, config.db_type === "oracle" ? "schemas-v2" : "schemas", showSystemSchemas ? "show-system" : "hide-system"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); else scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId, config, options); return; } } const schemas = await withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, effectiveDb), "schemas"); const visibleSchemas = filterSchemaNamesForConnection(schemas, schemaFilterConfig, effectiveDb || "", { showSystemSchemas }); const schemaNodes: TreeNode[] = sortSidebarNames(visibleSchemas).map((s) => ({ id: `${connectionId}:${s}:${s}`, label: s, type: "schema" as const, connectionId, database: s, schema: s, isExpanded: false, children: [], })); if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; recordPrimaryVisibleObjectNames(connectionId, schemas); setChildren(targetNode, withSavedSqlRoot(connectionId, schemaNodes, targetNode)); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || schemaNodes); } else { // Doris / StarRocks multi-catalog: when external catalogs exist, // render a catalog grouping layer (catalog → database → tables). // When only `internal` is present, fall through to the flat database // list (no regression for single-catalog deployments). let dorisCatalogs: CatalogInfo[] | null = null; if (connectionIsDorisFamilyCatalogCapable(config)) { dorisCatalogs = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogs(connectionId), "catalogs").catch((error: unknown) => { recordMetadataLoadError(connectionId, error, load); return null; }); } if (dorisCatalogs && dorisCatalogs.length > 1) { const cacheKey = schemaCacheKey(connectionId, "doris-catalogs"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; } } const catalogNodes: TreeNode[] = dorisCatalogs.map((catalog) => ({ id: dorisCatalogId(connectionId, catalog.name), label: catalog.name, type: "doris-catalog" as const, connectionId, catalog: catalog.name, catalogType: catalog.catalog_type, comment: catalog.comment ?? null, isExpanded: false, children: [], })); const children = withSavedSqlRoot(connectionId, catalogNodes, node); if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, children); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } else { const cacheKey = schemaCacheKey(connectionId, "databases-v2"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); else scheduleMissingPrimaryVisibleObjectNamesRefresh(connectionId, config, options); return; } } const databases = await withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"); const visibleNames = filterDatabaseNamesForConnection( databases.map((database) => database.name), config, ); const visibleNameSet = new Set(visibleNames); const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); const effectiveDbType = effectiveDatabaseTypeForConnection(config); const databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases), }); if (config?.db_type === "sqlserver") { const linkedServers = await withMetadataLoadTimeout(connectionId, api.listSqlServerLinkedServers(connectionId), "linked servers").catch(() => []); const linkedDatabase = sqlServerLinkedRuntimeDatabase(config); databaseNodes.push({ ...sqlServerLinkedRootNode(connectionId, linkedDatabase), children: linkedServers.map((server) => ({ id: sqlServerLinkedServerId(connectionId, server.name), label: server.name, type: "linked-server", connectionId, database: linkedDatabase, linkedServer: server.name, comment: [server.product, server.provider, server.data_source].filter(Boolean).join(" / ") || null, isExpanded: false, children: [], })), }); if (linkedServers.length > 0) loadedTreeNodeChildrenIds.value.add(sqlServerLinkedRootId(connectionId)); } const children = withSavedSqlRoot(connectionId, databaseNodes, node); if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; recordPrimaryVisibleObjectNames( connectionId, databases.map((database) => database.name), ); setChildren(targetNode, children); await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children); } } const liveNode = treeNodeLoadTarget(load); if (liveNode) liveNode.isExpanded = true; if (options?.force) void loadSidebarDatabaseStorage(connectionId, { force: true }); } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } }, options, ); } async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) { if (!connectedIds.value.has(connectionId)) return; const config = getConfig(connectionId); if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "elasticsearch", "easysearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return; const node = findConnectionNode(connectionId); if (!node || node.type !== "connection" || node.isLoading || hasConnectionMetadataChildren(node.children)) return; const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) }; if (metadataLoadCoordinator.has(scope)) return; const wasExpanded = !!node.isExpanded; const load = beginTreeNodeLoad(node); try { await loadDatabases(connectionId, { connectedOnly: true }); } finally { const liveNode = treeNodeInSidebarTree(node); if (liveNode) liveNode.isExpanded = wasExpanded; finishTreeNodeLoad(load); } } async function loadRedisDatabases(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const dbs = await withMetadataLoadTimeout(connectionId, api.redisListDatabases(connectionId), "Redis databases"); const config = getConfig(connectionId); const visibleNames = filterVisibleDatabaseNames( dbs.map((db) => String(db.db)), config?.visible_databases, ); const visibleNameSet = new Set(visibleNames); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; recordPrimaryVisibleObjectNames( connectionId, dbs.map((db) => String(db.db)), ); setChildren( targetNode, withSavedSqlRoot( connectionId, dbs .filter((db) => visibleNameSet.has(String(db.db))) .map((db) => ({ id: `${connectionId}:db${db.db}`, label: redisDatabaseLabel(db.db, config?.redis_database_aliases, db.keys), type: "redis-db" as const, connectionId, database: String(db.db), loadedKeyCount: 0, totalKeyCount: db.keys, isExpanded: false, children: [], })), targetNode, ), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadEtcdRoot(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, withSavedSqlRoot( connectionId, [ { id: `${connectionId}:etcd`, label: kvRootNodeLabel("etcd"), type: "etcd-root" as const, connectionId, database: "", isExpanded: false, children: [], }, { id: `${connectionId}:etcd-access-control`, label: "用户和角色", type: "etcd-access-control" as const, connectionId, database: "", isExpanded: false, children: [], }, { id: `${connectionId}:etcd-dashboard`, label: "服务仪表盘", type: "etcd-dashboard" as const, connectionId, database: "", isExpanded: false, children: [], }, ], targetNode, ), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadZooKeeperRoot(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, withSavedSqlRoot( connectionId, [ { id: `${connectionId}:zookeeper`, label: kvRootNodeLabel("zookeeper"), type: "zookeeper-root" as const, connectionId, database: "", isExpanded: false, children: [], }, ], targetNode, ), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadConsulRoot(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, withSavedSqlRoot( connectionId, [ { id: `${connectionId}:consul`, label: kvRootNodeLabel("consul"), type: "consul-root" as const, connectionId, database: "", isExpanded: false, children: [], }, { id: `${connectionId}:consul-overview`, label: i18n.global.t("consul.ui.overview"), type: "consul-overview" as const, connectionId, database: "", isExpanded: false, children: [], }, ], targetNode, ), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadMqttTopics(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; node.isLoading = true; try { await ensureConnected(connectionId); // MQTT subscription state belongs to the console. The global sidebar only // exposes a single navigation entry and must not keep a second topic tree. const consoleNode: TreeNode = { id: `${connectionId}:mqtt-topic:__console__`, label: "MQTT 控制台", type: "mqtt-topic" as const, connectionId, children: [], isExpanded: false, }; setChildren(node, [consoleNode]); node.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } finally { node.isLoading = false; } } async function loadMqTenants(connectionId: string, options?: LoadTreeOptions) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); if (isFlatMqConnection(config)) { // Kafka/RocketMQ have no tenant/namespace concept; RabbitMQ pins a synthetic // tenant and exposes virtual hosts as namespaces inside the console. Create a // synthetic child that opens the MQ admin console directly when clicked. const mqTenant = resolveMqSystemKindFromConnection(config) === "rabbitmq" ? RABBITMQ_MQ_TENANT : "_flat_mq"; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, [ { id: schemaCacheKey(connectionId, "mq-tenant", mqTenant), label: "Topics", type: "mq-tenant" as const, connectionId, mqTenant, mqInitialTab: "topics", }, ]); } else { const tenants = await withMetadataLoadTimeout(connectionId, api.mqListTenants(connectionId), "message queue tenants"); const tenantNames = sortSidebarNames(tenants.map((tenant) => tenant.name).filter((name) => !!name.trim())); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, tenantNames.map((tenant) => ({ id: schemaCacheKey(connectionId, "mq-tenant", tenant), label: tenant, type: "mq-tenant" as const, connectionId, mqTenant: tenant, })), ); } const liveNode = treeNodeLoadTarget(load); if (liveNode) liveNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadNacosNamespaces(connectionId: string, options?: LoadTreeOptions) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const namespaces = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(connectionId)); const visibleNamespaces = filterNacosNamespacesForSidebar(namespaces, getConfig(connectionId)?.visible_databases); const sorted = [...visibleNamespaces].sort((left, right) => { const leftLabel = left.namespaceShowName || left.namespace || "public"; const rightLabel = right.namespaceShowName || right.namespace || "public"; return leftLabel.localeCompare(rightLabel); }); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; recordPrimaryVisibleObjectNames( connectionId, namespaces.map((namespace) => namespace.namespace), ); setChildren( targetNode, sorted.map((namespace) => { const value = namespace.namespace || ""; const label = namespace.namespaceShowName || value || "public"; return { id: schemaCacheKey(connectionId, "nacos-namespace", value || "public"), label, type: "nacos-namespace" as const, connectionId, nacosNamespace: value, nacosNamespaceName: label, comment: namespace.namespaceDesc || null, objectCount: namespace.configCount, }; }), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } function updateRedisDbKeyStats(connectionId: string, db: number, stats: { loaded?: number; total?: number; totalDelta?: number }) { const node = findNode(treeNodes.value, `${connectionId}:db${db}`); if (!node || node.type !== "redis-db") return; if (stats.loaded != null) node.loadedKeyCount = stats.loaded; if (stats.total != null) node.totalKeyCount = stats.total; if (stats.totalDelta != null && node.totalKeyCount != null) { node.totalKeyCount = Math.max(0, node.totalKeyCount + stats.totalDelta); } node.label = redisDatabaseLabel(db, getConfig(connectionId)?.redis_database_aliases, node.totalKeyCount); } // Re-fetch the authoritative per-db key counts (INFO keyspace, lightweight) and update // the sidebar db nodes' counts in place — WITHOUT rebuilding the tree, so already-loaded // key trees under expanded db nodes are preserved. Used after a Redis write command so the // `dbN (count)` labels reflect the new reality without a manual refresh. async function refreshRedisDbKeyCounts(connectionId: string) { const connNode = findConnectionNode(connectionId); if (!connNode) return; try { await ensureConnected(connectionId); const dbs = await api.redisListDatabases(connectionId); for (const db of dbs) { updateRedisDbKeyStats(connectionId, db.db, { total: db.keys }); } } catch { // Best-effort: a failed count refresh must not disrupt the result view. } } async function loadMongoDatabases(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const dbs = await withMetadataLoadTimeout(connectionId, api.mongoListDatabases(connectionId), "MongoDB databases"); const config = getConfig(connectionId); const visibleDbs = filterDatabaseNamesForConnection(dbs, config); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; recordPrimaryVisibleObjectNames(connectionId, dbs); setChildren( targetNode, withSavedSqlRoot( connectionId, sortSidebarNames(visibleDbs).map((db) => ({ id: `${connectionId}:${db}`, label: db, type: "mongo-db" as const, connectionId, database: db, isExpanded: false, children: [], })), targetNode, ), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } /** * Connect an Elasticsearch root without expanding or listing indices. * Used when first opening a connection (test/connect) — connectivity uses * GET / or the configured check path via ensureConnected/test_connection. * Expanding the node lists indices via loadElasticsearchIndices. */ async function openElasticsearchConnectionTree(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; // Only ensure connectivity (GET / or configured path); do not expand or list indices. try { await ensureConnected(connectionId); } catch (e) { recordMetadataLoadError(connectionId, e); throw e; } } async function loadElasticsearchIndices(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const indices = await withMetadataLoadTimeout(connectionId, api.elasticsearchListIndices(connectionId), "Elasticsearch indices"); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, withSavedSqlRoot( connectionId, sortSidebarNames(indices).map((index) => ({ id: `${connectionId}:__collection:${index}`, label: index, type: "elasticsearch-index" as const, connectionId, database: "default", isExpanded: false, })), targetNode, ), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadMilvusDatabases(connectionId: string) { const node = findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const dbs = await withMetadataLoadTimeout(connectionId, api.documentListDatabases(connectionId), "Milvus databases"); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, withSavedSqlRoot( connectionId, sortSidebarNames(dbs).map((db) => ({ id: `${connectionId}:${db}`, label: db, type: "vector-database" as const, connectionId, database: db, isExpanded: false, children: [], })), targetNode, ), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadVectorCollections(connectionId: string, database?: string) { const config = getConfig(connectionId); const isMilvus = config?.db_type === "milvus"; const effectiveDb = database || config?.database || "default"; // Milvus groups collections under a per-database node; other vector stores stay flat under the connection. const node = isMilvus && database ? findNode(treeNodes.value, `${connectionId}:${database}`) : findConnectionNode(connectionId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const collections = await withMetadataLoadTimeout(connectionId, api.vectorListCollections(connectionId, effectiveDb), "vector collections"); const sorted = [...collections].sort((a, b) => a.name.localeCompare(b.name)); const collectionChildren = sorted.map((info) => ({ // Include the database for Milvus so same-named collections across databases don't collide. id: `${connectionId}:__vector_collection:${isMilvus ? `${effectiveDb}:${info.id}` : info.id}`, label: info.name, type: "vector-collection" as const, connectionId, database: effectiveDb, isExpanded: false, meta: { dimension: info.dimension, collectionId: info.id } as VectorCollectionMeta, })); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, isMilvus && database ? collectionChildren : withSavedSqlRoot(connectionId, collectionChildren, targetNode)); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadMongoCollections(connectionId: string, database: string) { const nodeId = `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; const load = beginTreeNodeLoad(node); try { const collections = await api.mongoListCollections(connectionId, database); const bucketNames = new Set(collections.filter((c) => c.kind === "bucket" && c.bucketName).map((c) => c.bucketName as string)); const hiddenCollectionNames = new Set([...bucketNames].flatMap((bucketName) => [`${bucketName}.files`, `${bucketName}.chunks`])); const collectionEntries = collections.filter((c) => c.kind !== "bucket").filter((c) => !hiddenCollectionNames.has(c.name)); const collectionChildren = [...collectionEntries] .sort((left, right) => compareSidebarNames(left.name, right.name)) .map((col) => ({ id: `${nodeId}:${col.name}`, label: col.name, type: "mongo-collection" as const, connectionId, database, meta: { collectionKind: toMongoCollectionKind(col.kind) }, isExpanded: false, })); const children = [ { id: `${nodeId}:__gridfs`, label: i18n.global.t("tree.gridfs"), type: "mongo-gridfs" as const, connectionId, database, isExpanded: false, }, ...collectionChildren, ]; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, children); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadSchemas(connectionId: string, database: string, options?: LoadTreeOptions) { const configForScope = getConfig(connectionId); return runTreeMetadataLoad( { kind: "database-schemas", connectionId, database, driverProfile: metadataDriverProfile(configForScope), }, async () => { const nodeId = `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); const showSystemSchemas = config?.show_system_schemas === true; const cacheVersion = ownerAwareMetadataCacheVersion(config, "schemas-v3"); const cacheKey = schemaCacheKey(connectionId, database, cacheVersion, showSystemSchemas ? "show-system" : "hide-system"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; } } const schemas = sortSidebarSchemaInfos(await withMetadataLoadTimeout(connectionId, api.listSchemaInfos(connectionId, database), "schemas")); const visibleSchemaNames = new Set( filterSchemaNamesForConnection( schemas.map((schema) => schema.name), getConfig(connectionId), database, { showSystemSchemas }, ), ); const children: TreeNode[] = schemas .filter((schema) => visibleSchemaNames.has(schema.name)) .map((schema) => { const s = schema.name; return { id: `${connectionId}:${database}:${s}`, label: s, type: "schema" as const, connectionId, database, schema: s, comment: schema.comment, isExpanded: false, children: [], }; }); if (schemas.length === 0 && connectionShouldDiscoverJdbcSchemas(getConfig(connectionId))) { // Generic JDBC drivers vary widely: prefer schema navigation when the // driver reports schemas, but keep the legacy flat object tree when it // reports none so non-schema engines do not expand into an empty node. await loadTables(connectionId, database, undefined, options); return; } if (isPostgresLikeForExtensions(getConfig(connectionId)?.db_type)) { children.push(buildExtensionManagementNode(connectionId, database)); } if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, children); await savePersistedTreeChildren(cacheKey, children); const currentTargetNode = treeNodeLoadTarget(load); if (currentTargetNode) currentTargetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } }, options, ); } async function loadSqlServerDatabaseObjects(connectionId: string, database: string, options?: LoadTreeOptions) { const nodeId = `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const config = getConfig(connectionId); const showSystemSchemas = config?.show_system_schemas === true; const cacheKey = schemaCacheKey(connectionId, database, simpleObjectDisplay ? "sqlserver-schemas-simple-v4" : "sqlserver-schemas-grouped-v4", showSystemSchemas ? "show-system" : "hide-system"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; } } const schemas = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), config, database, { showSystemSchemas }); const children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas); if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, children); await savePersistedTreeChildren(cacheKey, children); const currentTargetNode = treeNodeLoadTarget(load); if (currentTargetNode) currentTargetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadSqlServerLinkedServers(connectionId: string, options?: LoadTreeOptions) { const node = findNode(treeNodes.value, sqlServerLinkedRootId(connectionId)); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); const database = sqlServerLinkedRuntimeDatabase(config); const linkedServers = await api.listSqlServerLinkedServers(connectionId); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, linkedServers.map((server) => ({ id: sqlServerLinkedServerId(connectionId, server.name), label: server.name, type: "linked-server" as const, connectionId, database, linkedServer: server.name, comment: [server.product, server.provider, server.data_source].filter(Boolean).join(" / ") || null, isExpanded: false, children: [], })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadSqlServerLinkedServerCatalogs(node: TreeNode, options?: LoadTreeOptions) { if (!node.connectionId || !node.linkedServer) return; const connectionId = node.connectionId; const server = node.linkedServer; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const catalogs = await api.listSqlServerLinkedServerCatalogs(connectionId, server); const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(connectionId)); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, catalogs .filter((catalog) => catalog.name.trim()) .map((catalog) => ({ id: sqlServerLinkedCatalogId(connectionId, server, catalog.name), label: catalog.name, type: "linked-server-catalog" as const, connectionId, database, linkedServer: server, linkedCatalog: catalog.name, isExpanded: false, children: [], })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadSqlServerLinkedServerSchemas(node: TreeNode, options?: LoadTreeOptions) { if (!node.connectionId || !node.linkedServer || !node.linkedCatalog) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(node.connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const schemas = await api.listSqlServerLinkedServerSchemas(node.connectionId, node.linkedServer, node.linkedCatalog); const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(node.connectionId)); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, sortSidebarNames(schemas) .filter((schema) => schema.trim()) .map((schema) => { const encodedSchema = encodeSqlServerLinkedSchema({ server: targetNode.linkedServer!, catalog: targetNode.linkedCatalog!, schema, }); return { id: `${targetNode.connectionId}:${database}:${encodedSchema}`, label: schema, type: "linked-server-schema" as const, connectionId: targetNode.connectionId, database, schema: encodedSchema, linkedServer: targetNode.linkedServer, linkedCatalog: targetNode.linkedCatalog, linkedSchema: schema, isExpanded: false, children: [], }; }), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(node.connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } // Doris / StarRocks multi-catalog: load the databases under a catalog node. async function loadDorisCatalogDatabases(node: TreeNode, options?: LoadTreeOptions) { if (!node.connectionId || !node.catalog) return; const connectionId = node.connectionId; const catalog = node.catalog; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const config = getConfig(connectionId); const databases = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogDatabases(connectionId, catalog), "databases"); const visibleNames = filterDatabaseNamesForConnection( databases.map((database) => database.name), config, ); const visibleNameSet = new Set(visibleNames); const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); let databaseNodes: TreeNode[]; if (isInternalDorisCatalog(node.catalogType, catalog)) { // The internal catalog's databases are rendered as standard database // nodes so they reuse the existing table-loading / table-open paths. // Detection is type-based (catalogType=`internal`), so StarRocks // `default_catalog` routes here too — its tables carry no catalog. databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { includeDefaultWhenEmpty: false }); } else { databaseNodes = sortSidebarDatabases(visibleDatabases).flatMap((database) => { const name = database.name.trim(); if (!name) return []; return [ { id: dorisCatalogDatabaseId(connectionId, catalog, name), label: name, type: "database" as const, connectionId, database: name, catalog, isExpanded: false, children: [], }, ]; }); } if (isSidebarSearchQueryChanged(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, databaseNodes); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } // Doris / StarRocks multi-catalog: load tables under an external-catalog // database node. External catalogs only expose tables/views, so a flat list // (no routines/sequences/triggers) is rendered. async function loadDorisCatalogTables(node: TreeNode, options?: LoadTreeOptions) { if (!node.connectionId || !node.database || !node.catalog) return; const connectionId = node.connectionId; const { database, catalog } = node; const configForScope = getConfig(connectionId); const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined; return runTreeMetadataLoad( { kind: "schema-tables", connectionId, database, schema: undefined, nodeKind: "database", objectTypes: objectTypesForScope, searchFilter: activeTreeLoadSearchFilter(options), limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined, offset: 0, sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped", driverProfile: metadataDriverProfile(configForScope), extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined, }, async () => { let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const searchFilter = activeTreeLoadSearchFilter(options); const tableNameFilter = activeTableNameFilterForScope({ connectionId, database, nodeKind: "simple-tables", catalog, }); const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v5"); if (!options?.force && !searchFilter && !tableNameFilter) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; } } const pageSize = sidebarObjectGroupPageSize(); const fetchLimit = searchFilter ? pageSize : pageSize + 1; const fetchOffset = searchFilter ? undefined : 0; const tables = await withMetadataLoadTimeout(connectionId, listTablesWithOptionalTableNameFilter(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog, tableNameFilter), "tables"); const hasMore = searchFilter ? false : tables.length > pageSize; const pageTables = hasMore ? tables.slice(0, pageSize) : tables; indexCompletionTables(connectionId, database, undefined, tableInfosToCompletionTables(pageTables, undefined), catalog); let children = buildTableTreeNodes({ nodeId: node.id, connectionId, database, schema: undefined, tables: pageTables, catalog, }); if (hasMore && !searchFilter) { children = [...children, buildLoadMoreNode(node, pageSize, pageSize)]; } if (isTreeLoadSearchChanged(searchFilter, options)) return; if (!tableNameFilterRevisionMatches(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; targetNode.objectCount = children.filter((child) => child.type !== "load-more").length; setChildren(targetNode, children); if (!searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); } const currentTargetNode = treeNodeLoadTarget(load); if (currentTargetNode) currentTargetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } }, options, ); } async function loadTables(connectionId: string, database: string, schema?: string, options?: LoadTreeOptions) { const configForScope = getConfig(connectionId); const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined; const searchFilter = activeTreeLoadSearchFilter(options); const querySchemaForScope = connectionObjectTreeQuerySchema(configForScope, database, schema); const effectiveSchemaForScope = connectionObjectTreeNodeSchema(configForScope, database, schema); const tableNameFilterForScope = activeTableNameFilterForScope({ connectionId, database, schema: effectiveSchemaForScope ?? querySchemaForScope, nodeKind: simpleObjectDisplayForScope ? "simple-tables" : "group-tables", }); if (!options?.force && simpleObjectDisplayForScope && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) { const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`; const cacheKey = schemaCacheKey(connectionId, database, schema || "", ownerAwareMetadataCacheVersion(configForScope, "objects-simple-v6")); if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) { void loadTables(connectionId, database, schema, { ...options, force: true }).catch(() => undefined); return; } } return runTreeMetadataLoad( { kind: "schema-tables", connectionId, database, schema, nodeKind: schema ? "schema" : "database", objectTypes: objectTypesForScope, searchFilter: activeTreeLoadSearchFilter(options), limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined, offset: 0, sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped", driverProfile: metadataDriverProfile(configForScope), extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined, }, async () => { const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`; const node = findNode(treeNodes.value, nodeId); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const searchFilter = activeTreeLoadSearchFilter(options); const config = getConfig(connectionId); const cacheVersion = ownerAwareMetadataCacheVersion(config, simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7"); const cacheKey = schemaCacheKey(connectionId, database, schema || "", cacheVersion); const querySchema = connectionObjectTreeQuerySchema(config, database, schema); const effectiveSchema = connectionObjectTreeNodeSchema(config, database, schema); const tableNameFilter = activeTableNameFilterForScope({ connectionId, database, schema, nodeKind: simpleObjectDisplay ? "simple-tables" : "group-tables", }); const isSidebarTableSearch = !!options?.sidebarTableSearchParentId; if (!options?.force && !searchFilter && !tableNameFilter) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; } } const nonTableObjectTypes = simpleObjectDisplay ? supportedSidebarObjectTypes(config).filter((objectType) => objectType !== "TABLE") : []; let children: TreeNode[]; let nextObjectCount: number | undefined; if (simpleObjectDisplay) { const pageSize = sidebarObjectGroupPageSize(); const page = await loadPagedSimpleTableChildren({ nodeId, connectionId, database, querySchema, effectiveSchema, nonTableObjectTypes, offset: 0, pageSize, searchFilter: searchFilter || undefined, force: options?.force, }); children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children; nextObjectCount = page.objectCount; } else { children = buildObjectGroupPlaceholderNodes({ nodeId, connectionId, database, schema: effectiveSchema, objectTypes: supportedSidebarObjectTypes(config), }); if (!schema && isPostgresLikeForExtensions(config?.db_type)) { children.push(buildExtensionManagementNode(connectionId, database)); } } if (isTreeLoadSearchChanged(searchFilter, options)) return; if (!tableNameFilterRevisionMatches(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; if (nextObjectCount !== undefined) targetNode.objectCount = nextObjectCount; setChildren(targetNode, children); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); } const currentTargetNode = treeNodeLoadTarget(load); if (!currentTargetNode) return; currentTargetNode.isExpanded = true; if (simpleObjectDisplay && !searchFilter && !isSidebarTableSearch && nonTableObjectTypes.length > 0) { void loadSimpleSupplementalObjectChildren({ node: currentTargetNode, nodeId, connectionId, database, querySchema, effectiveSchema, objectTypes: nonTableObjectTypes, cacheKey, loadOptions: options, load, }); } } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } }, options, ); } async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions) { const configForScope = node.connectionId ? getConfig(node.connectionId) : undefined; const objectTypesForScope = objectTypesForGroupNode(node.type); const pageSizeForScope = sidebarObjectGroupPageSize(); const searchFilter = activeTreeLoadSearchFilter(options); const querySchemaForScope = connectionObjectTreeQuerySchema(configForScope, node.database || "", node.schema); const effectiveSchemaForScope = connectionObjectTreeNodeSchema(configForScope, node.database || "", node.schema); const tableNameFilterForScope = activeTableNameFilterForScope({ connectionId: node.connectionId, database: node.database, schema: effectiveSchemaForScope ?? querySchemaForScope, nodeKind: node.type, catalog: node.catalog, }); if (!options?.force && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) { if (await hydrateTreeNodeFromCache(node, objectGroupCacheKey(node))) { void loadObjectGroupChildren(node, { ...options, force: true }).catch(() => undefined); return; } } return runTreeMetadataLoad( { kind: "object-group", connectionId: node.connectionId, database: node.database, schema: node.schema, nodeKind: node.type, objectTypes: objectTypesForScope, searchFilter: activeTreeLoadSearchFilter(options), limit: pageSizeForScope + 1, offset: 0, sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay, driverProfile: metadataDriverProfile(configForScope), extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined, }, async () => { if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(node.connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) return; const objectTypes = objectTypesForGroupNode(node.type); const parentNodeId = objectGroupRefreshParentId(node); if (!objectTypes || !parentNodeId) return; const config = getConfig(node.connectionId); const querySchema = connectionObjectTreeQuerySchema(config, node.database, node.schema); const effectiveSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema); const cacheKey = objectGroupCacheKey(node); const searchFilter = activeTreeLoadSearchFilter(options); const tableNameFilter = activeTableNameFilterForScope({ connectionId: node.connectionId, database: node.database, schema: node.schema, nodeKind: node.type, catalog: node.catalog, }); const isSidebarTableSearch = !!options?.sidebarTableSearchParentId; if (!options?.force && !searchFilter && !tableNameFilter) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { if (cached.isStale) refreshStaleTreeNode(node); return; } } const wantsOnlyTablesOrViews = objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW"); let children: TreeNode[]; let nextObjectCount: number; if (wantsOnlyTablesOrViews) { const page = await loadPagedTableGroupChildren({ node, parentNodeId, querySchema, effectiveSchema, objectTypes, offset: 0, pageSize: sidebarObjectGroupPageSize(), searchFilter: searchFilter || undefined, force: options?.force, }); children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize()), page.loadMoreParent) : page.children; nextObjectCount = page.objectCount; } else { const pageSize = sidebarObjectGroupPageSize(); const page = await loadPagedObjectGroupChildren({ node, parentNodeId, querySchema, effectiveSchema, objectTypes, offset: 0, pageSize, searchFilter: searchFilter || undefined, force: options?.force, }); children = page.hasMore && !searchFilter ? [...page.children, buildLoadMoreNode(node, page.nextOffset, pageSize)] : page.children; nextObjectCount = page.objectCount; } if (isTreeLoadSearchChanged(searchFilter, options)) return; if (!tableNameFilterRevisionMatches(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; targetNode.objectCount = nextObjectCount; setChildren(targetNode, children); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); } const currentTargetNode = treeNodeLoadTarget(load); if (currentTargetNode) currentTargetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(node.connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } }, options, ); } async function loadMoreObjectGroupChildren(node: TreeNode) { if (node.type !== "load-more" || !node.loadMore) return; const loadMore = node.loadMore; const parent = findNode(treeNodes.value, node.loadMore.parentId); if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return; const parentConnectionId = parent.connectionId; const configForScope = getConfig(parentConnectionId); const objectTypesForScope = objectTypesForGroupNode(parent.type); return runTreeMetadataLoad( { kind: "object-group-page", connectionId: parentConnectionId, database: parent.database, schema: parent.schema, nodeKind: parent.type, objectTypes: objectTypesForScope, limit: loadMore.pageSize + 1, offset: loadMore.offset, sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay, driverProfile: metadataDriverProfile(configForScope), }, async () => { let load = beginTreeNodeLoad(node); // Parent writes must honor the parent's generation too — load-more begins on the // placeholder node, so a replaced/invalidated parent must reject the merge. const parentEpoch = treeNodeLoads.observe(parent.id); try { await ensureConnected(parentConnectionId); load = reclaimTreeNodeLoad(load, node); if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") { const parentDatabase = parent.database; if (!parentDatabase) return; const config = getConfig(parentConnectionId); const querySchema = connectionObjectTreeQuerySchema(config, parentDatabase, parent.schema); const effectiveSchema = connectionObjectTreeNodeSchema(config, parentDatabase, parent.schema); const page = await loadPagedSimpleTableChildren({ nodeId: parent.schema ? `${parentConnectionId}:${parentDatabase}:${parent.schema}` : `${parentConnectionId}:${parentDatabase}`, connectionId: parentConnectionId, database: parentDatabase, querySchema, effectiveSchema, nonTableObjectTypes: [], offset: loadMore.offset, pageSize: loadMore.pageSize, force: false, }); const targetParent = treeNodeLoadRelatedTarget(load, parent); if (!targetParent || !parentEpoch.isCurrent()) return; const currentChildren = withoutTableTreeLoadMoreNodes(targetParent.children); const mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase); const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren; targetParent.objectCount = mergedChildren.length; setChildren(targetParent, nextChildren); await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v6")), nextChildren); const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; return; } const objectTypes = objectTypesForGroupNode(parent.type); const parentNodeId = objectGroupRefreshParentId(parent); if (!objectTypes || !parentNodeId) return; const config = getConfig(parentConnectionId); const parentDatabase = parent.database; if (!parentDatabase) return; const querySchema = connectionObjectTreeQuerySchema(config, parentDatabase, parent.schema); const effectiveSchema = connectionObjectTreeNodeSchema(config, parentDatabase, parent.schema); const wantsOnlyTablesOrViews = objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW"); let mergedChildren: TreeNode[]; let nextChildren: TreeNode[]; if (wantsOnlyTablesOrViews) { const page = await loadPagedTableGroupChildren({ node: parent, parentNodeId, querySchema, effectiveSchema, objectTypes, offset: loadMore.offset, pageSize: loadMore.pageSize, force: false, }); const targetParent = treeNodeLoadRelatedTarget(load, parent); if (!targetParent || !parentEpoch.isCurrent()) return; const currentChildren = withoutTableTreeLoadMoreNodes(targetParent.children); mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase); nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren; } else { const page = await loadPagedObjectGroupChildren({ node: parent, parentNodeId, querySchema, effectiveSchema, objectTypes, offset: loadMore.offset, pageSize: loadMore.pageSize, force: false, }); const targetParent = treeNodeLoadRelatedTarget(load, parent); if (!targetParent || !parentEpoch.isCurrent()) return; const currentChildren = withoutLoadMoreNodes(targetParent.children); mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, parentConnectionId, parentDatabase); nextChildren = page.hasMore ? [...mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize)] : mergedChildren; targetParent.objectCount = mergedChildren.length; setChildren(targetParent, nextChildren); await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; return; } const targetParent = treeNodeLoadRelatedTarget(load, parent); if (!targetParent || !parentEpoch.isCurrent()) return; targetParent.objectCount = mergedChildren.length; setChildren(targetParent, nextChildren); await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren); const currentTargetParent = treeNodeLoadRelatedTarget(load, parent); if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true; } catch (e) { recordMetadataLoadError(parentConnectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } }, ); } async function loadExtensions(connectionId: string, database: string) { const node = findNode(treeNodes.value, `${connectionId}:${database}:__extensions`); if (!node) return; let load = beginTreeNodeLoad(node); try { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, undefined, load)) return; const extensions = await withMetadataLoadTimeout(connectionId, api.listExtensions(connectionId, database), "extensions"); const children: TreeNode[] = extensions.map((ext) => ({ id: `${node.id}:${ext.schema || ""}:${ext.name}`, label: ext.name, type: "extension" as const, connectionId, database, schema: ext.schema ?? undefined, comment: ext.comment ?? null, meta: ext, isExpanded: false, })); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, children); targetNode.objectCount = children.length; targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadTableForLocate(target: LocateTableTarget): Promise { const config = getConfig(target.connectionId); if (!config) return false; return runTreeMetadataLoad( { kind: "locate-target", connectionId: target.connectionId, database: target.database, schema: target.schema, tableName: target.tableName, searchFilter: target.tableName, limit: sidebarObjectGroupPageSize() + 1, offset: 0, sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay, driverProfile: metadataDriverProfile(config), }, async () => { await ensureConnected(target.connectionId); const querySchema = connectionObjectTreeQuerySchema(config, target.database, target.schema); const effectiveSchema = connectionObjectTreeNodeSchema(config, target.database, target.schema); const pageSize = sidebarObjectGroupPageSize(); const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; let loaded = false; if (simpleObjectDisplay) { const parentId = target.schema ? `${target.connectionId}:${target.database}:${target.schema}` : `${target.connectionId}:${target.database}`; const parent = findNode(treeNodes.value, parentId); if (!parent) return false; let load = beginTreeNodeLoad(parent); try { load = reclaimTreeNodeLoad(load, parent); const page = await loadPagedSimpleTableChildren({ nodeId: parentId, connectionId: target.connectionId, database: target.database, querySchema, effectiveSchema, nonTableObjectTypes: [], offset: 0, pageSize, searchFilter: target.tableName, force: false, }); if (!page.children.length) return false; const targetParent = treeNodeLoadTarget(load); if (!targetParent) return false; const currentChildren = withoutLoadMoreNodes(targetParent.children); const loadMoreNodes = (targetParent.children || []).filter((child) => child.type === "load-more"); const mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, target.connectionId, target.database); setChildren(targetParent, [...mergedChildren, ...loadMoreNodes]); targetParent.objectCount = Math.max(targetParent.objectCount ?? currentChildren.length, mergedChildren.length); targetParent.isExpanded = true; return true; } finally { finishTreeNodeLoad(load); } } const matchingGroups = findTreeNodes(treeNodes.value, (node) => { return (node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views") && node.connectionId === target.connectionId && sameSidebarObjectName(node.database, target.database) && (!target.schema || sameSidebarObjectName(node.schema, target.schema)); }); for (const group of matchingGroups) { const objectTypes = objectTypesForGroupNode(group.type); const parentNodeId = objectGroupRefreshParentId(group); if (!objectTypes || !parentNodeId) continue; let load = beginTreeNodeLoad(group); try { load = reclaimTreeNodeLoad(load, group); const page = await loadPagedTableGroupChildren({ node: group, parentNodeId, querySchema, effectiveSchema, objectTypes, offset: 0, pageSize, searchFilter: target.tableName, force: false, }); if (!page.children.length) continue; const targetGroup = treeNodeLoadTarget(load); if (!targetGroup) continue; const currentChildren = withoutLoadMoreNodes(targetGroup.children); const loadMoreNodes = (targetGroup.children || []).filter((child) => child.type === "load-more"); const mergedChildren = mergeLocatedTreeChildren(targetGroup, currentChildren, page.children, target.connectionId, target.database); setChildren(targetGroup, [...mergedChildren, ...loadMoreNodes]); targetGroup.objectCount = Math.max(targetGroup.objectCount ?? currentChildren.length, mergedChildren.length); targetGroup.isExpanded = true; loaded = true; } finally { finishTreeNodeLoad(load); } } return loaded; }, ); } async function loadAllObjectGroupChildren(parent: TreeNode) { if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return; if (!objectTypesForGroupNode(parent.type)) return; let load = beginTreeNodeLoad(parent); try { await ensureConnected(parent.connectionId); load = reclaimTreeNodeLoad(load, parent); const liveParent = treeNodeLoadTarget(load); if (!liveParent) return; if (!isTreeNodeChildrenLoaded(liveParent.id)) { await loadObjectGroupChildren(liveParent); } if (!load.isCurrent()) return; let loadMoreNode = findTreeNodes(liveParent.children ?? [], (child) => child.type === "load-more")[0]; while (loadMoreNode?.loadMore) { await loadMoreObjectGroupChildren(loadMoreNode); if (!load.isCurrent()) return; const currentParent = treeNodeLoadTarget(load); if (!currentParent) return; loadMoreNode = findTreeNodes(currentParent.children ?? [], (child) => child.type === "load-more")[0]; } const finishedParent = treeNodeLoadTarget(load); if (finishedParent) finishedParent.isExpanded = true; } catch (e) { recordMetadataLoadError(parent.connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } function setSidebarTableSearchQuery(parentNodeId: string, query: string) { const normalized = query.trim(); const next = { ...sidebarTableSearchQueries.value }; if (normalized) { next[parentNodeId] = query; } else { delete next[parentNodeId]; } sidebarTableSearchQueries.value = next; } async function refreshSidebarTableSearch(parentNodeId: string) { const parent = findNode(treeNodes.value, parentNodeId); if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return; const searchFilter = sidebarTableSearchQueries.value[parentNodeId]?.trim() || ""; const options: LoadTreeOptions = { force: true, searchFilter: searchFilter || undefined, sidebarTableSearchParentId: parentNodeId, expectedSidebarTableSearchQuery: searchFilter, }; if (parent.type === "group-tables") { await loadObjectGroupChildren(parent, options); return; } if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") { await loadTables(parent.connectionId, parent.database, parent.schema, options); } } function normalizedObjectTreeKind(type: string): DatabaseObjectTreeKind { return normalizeSidebarObjectKind(type); } async function loadTableGroups(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}` : `${connectionId}:${database}:${table}`); const node = findNode(treeNodes.value, parentId); if (!node) return; let load = beginTreeNodeLoad(node); try { const children: TreeNode[] = [ ...tablePartitionGroups(node), { id: `${parentId}:__columns`, label: "tree.columns", type: "group-columns", connectionId, database, schema, catalog, tableName: table, isExpanded: false, children: [], }, ]; const config = getConfig(connectionId); const effectiveDbType = effectiveDatabaseTypeForConnection(config); const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType); const isXugu = effectiveDbType === "xugu"; const supportsXuguChildMetadata = isXugu && node.type === "table" && (await supportsXuguTableChildMetadata()); load = reclaimTreeNodeLoad(load, node); if (supportsXuguChildMetadata) { children.push({ id: `${parentId}:__constraints`, label: "tree.constraints", type: "group-constraints", connectionId, database, schema, catalog, tableName: table, isExpanded: false, children: [], }); } const isMongoView = node.type === "mongo-collection" && mongoCollectionKindFromNode(node) === "view"; if ((node.type === "table" || node.type === "mongo-collection") && !isMongoView && !parseSqlServerLinkedSchema(schema)) { if (metadataCapabilities.indexes && !isXugu) { children.push({ id: `${parentId}:__indexes`, label: "tree.indexes", type: "group-indexes", connectionId, database, schema, catalog, tableName: table, // Keep the Mongo collection kind available to index actions so // views do not offer unsupported index creation or deletion. meta: node.type === "mongo-collection" ? node.meta : undefined, isExpanded: false, children: [], }); } } if (node.type === "table" && !parseSqlServerLinkedSchema(schema)) { if (metadataCapabilities.foreignKeys) { children.push({ id: `${parentId}:__fkeys`, label: "tree.foreignKeys", type: "group-fkeys", connectionId, database, schema, catalog, tableName: table, isExpanded: false, children: [], }); } if (metadataCapabilities.triggers) { children.push({ id: `${parentId}:__triggers`, label: "tree.triggers", type: "group-triggers", connectionId, database, schema, catalog, tableName: table, isExpanded: false, children: [], }); } if (isXugu) { if (metadataCapabilities.indexes) { children.push({ id: `${parentId}:__indexes`, label: "tree.indexes", type: "group-indexes", connectionId, database, schema, catalog, tableName: table, isExpanded: false, children: [], }); } if (supportsXuguChildMetadata) { children.push( { id: `${parentId}:__table-partitions`, label: "tree.partitions", type: "group-table-partitions", connectionId, database, schema, catalog, tableName: table, isExpanded: false, children: [], }, { id: `${parentId}:__table-subpartitions`, label: "tree.subpartitions", type: "group-table-subpartitions", connectionId, database, schema, catalog, tableName: table, isExpanded: false, children: [], }, ); } } } const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, children); targetNode.isExpanded = true; } finally { finishTreeNodeLoad(load); } } async function loadColumns(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__columns` : `${connectionId}:${database}:${table}:__columns`); const node = findNode(treeNodes.value, parentId); if (!node) return; const load = beginTreeNodeLoad(node); try { if (effectiveDatabaseTypeForConnection(getConfig(connectionId)) === "mongodb") { const fields = await listMongoCompletionFields(connectionId, database, table); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, fields.map((field) => { const column = { name: field.name, data_type: field.type || "unknown", is_nullable: true, column_default: null, is_primary_key: field.name === "_id", extra: "sampled", }; return { id: `${parentId}:${field.name}`, label: `${field.name} (${column.data_type})`, type: "column" as const, connectionId, database, tableName: table, meta: column, }; }), ); targetNode.isExpanded = true; return; } const querySchema = metadataQuerySchema(connectionId, database, schema); const columns = await api.getColumns(connectionId, database, querySchema, table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, columns.map((col) => ({ id: `${parentId}:${col.name}`, label: `${col.name} (${col.data_type})`, type: "column" as const, connectionId, database, schema, tableName: table, meta: col, })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadIndexes(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__indexes` : `${connectionId}:${database}:${table}:__indexes`); const node = findNode(treeNodes.value, parentId); if (!node) return; const load = beginTreeNodeLoad(node); try { const effectiveDbType = effectiveDatabaseTypeForConnection(getConfig(connectionId)); const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType); const isMongoView = effectiveDbType === "mongodb" && node.type === "group-indexes" && mongoCollectionKindFromNode(node) === "view"; if (!metadataCapabilities.indexes || isMongoView) { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, []); targetNode.isExpanded = true; return; } const querySchema = metadataQuerySchema(connectionId, database, schema); const indexes = await api.listIndexes(connectionId, database, querySchema, table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; const mongoCollectionKind = effectiveDbType === "mongodb" && targetNode.type === "group-indexes" ? mongoCollectionKindFromNode(targetNode) : undefined; setChildren( targetNode, indexes.map((idx) => ({ id: `${parentId}:${idx.name}`, label: `${idx.name} (${idx.columns.join(", ")})`, type: "index" as const, connectionId, database, schema, tableName: table, meta: mongoCollectionKind ? { ...idx, collectionKind: mongoCollectionKind } : idx, })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadForeignKeys(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__fkeys` : `${connectionId}:${database}:${table}:__fkeys`); const node = findNode(treeNodes.value, parentId); if (!node) return; const load = beginTreeNodeLoad(node); try { const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId))); if (!metadataCapabilities.foreignKeys) { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, []); targetNode.isExpanded = true; return; } const querySchema = metadataQuerySchema(connectionId, database, schema); const fkeys = await api.listForeignKeys(connectionId, database, querySchema, table, catalog); const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`; completionForeignKeysCache.value[cacheKey] = fkeys; evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX); indexCompletionForeignKeys(connectionId, database, table, schema, sqlCompletionForeignKeys(fkeys)); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, fkeys.map((fk) => ({ id: `${parentId}:${fk.name}`, label: `${fk.column} → ${fk.ref_table}.${fk.ref_column}`, type: "fkey" as const, connectionId, database, schema, tableName: table, meta: fk, })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadTriggers(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__triggers` : `${connectionId}:${database}:${table}:__triggers`); const node = findNode(treeNodes.value, parentId); if (!node) return; const load = beginTreeNodeLoad(node); try { const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId))); if (!metadataCapabilities.triggers) { const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, []); targetNode.isExpanded = true; return; } const querySchema = metadataQuerySchema(connectionId, database, schema); const triggers = await api.listTriggers(connectionId, database, querySchema, table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, triggers.map((tr) => ({ id: `${parentId}:${tr.name}`, label: `${tr.name} (${tr.timing} ${tr.event})`, objectName: tr.name, type: "trigger" as const, connectionId, database, schema, tableName: table, meta: tr, })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadConstraints(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__constraints`; const node = findNode(treeNodes.value, parentId); if (!node) return; const load = beginTreeNodeLoad(node); try { const constraints = await api.listConstraints(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, constraints.map((constraint) => ({ id: `${parentId}:${constraint.name}`, label: `${constraint.name} (${constraint.constraint_type})${constraint.valid ? "" : " · INVALID"}`, type: "constraint" as const, connectionId, database, schema, tableName: table, meta: constraint, })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadPartitions(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-partitions`; const node = findNode(treeNodes.value, parentId); if (!node) return; const load = beginTreeNodeLoad(node); try { const partitions = await api.listPartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, partitions.map((partition) => ({ id: `${parentId}:${partition.name}`, label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`, type: "partition" as const, connectionId, database, schema, tableName: table, meta: partition, })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } async function loadSubpartitions(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) { const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-subpartitions`; const node = findNode(treeNodes.value, parentId); if (!node) return; const load = beginTreeNodeLoad(node); try { const partitions = await api.listSubpartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren( targetNode, partitions.map((partition) => ({ id: `${parentId}:${partition.name}`, label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`, type: "subpartition" as const, connectionId, database, schema, tableName: table, meta: partition, })), ); targetNode.isExpanded = true; } catch (e) { recordMetadataLoadError(connectionId, e, load); throw e; } finally { finishTreeNodeLoad(load); } } function collectExpandedNodeIds(nodes: TreeNode[], ids = new Set()): Set { for (const node of nodes) { if (node.isExpanded) ids.add(node.id); if (node.children) collectExpandedNodeIds(node.children, ids); } return ids; } async function loadTreeNodeChildren(node: TreeNode, options?: LoadTreeOptions) { if (node.type === "connection" && node.connectionId) { const config = getConfig(node.connectionId); if (config?.db_type === "redis") { await loadRedisDatabases(node.connectionId); } else if (config?.db_type === "etcd") { await loadEtcdRoot(node.connectionId); } else if (config?.db_type === "zookeeper") { await loadZooKeeperRoot(node.connectionId); } else if (config?.db_type === "consul") { await loadConsulRoot(node.connectionId); } else if (config?.db_type === "mongodb") { await loadMongoDatabases(node.connectionId); } else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") { await loadElasticsearchIndices(node.connectionId); } else if (config?.db_type === "milvus") { await loadMilvusDatabases(node.connectionId); } else if (config?.db_type === "qdrant" || config?.db_type === "weaviate" || config?.db_type === "chromadb") { await loadVectorCollections(node.connectionId); } else if (config?.db_type === "mq") { await loadMqTenants(node.connectionId, options); } else if (config?.db_type === "mqtt") { await loadMqttTopics(node.connectionId); } else if (config?.db_type === "nacos") { await loadNacosNamespaces(node.connectionId, options); } else { await loadDatabases(node.connectionId, options); } } else if (node.type === "mongo-db" && node.connectionId && node.database) { await loadMongoCollections(node.connectionId, node.database); } else if (node.type === "vector-database" && node.connectionId && node.database) { await loadVectorCollections(node.connectionId, node.database); } else if (node.type === "mongo-collection" && node.connectionId && node.database) { await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id); } else if (node.type === "mongo-gridfs") { node.isExpanded = true; } else if (node.type === "doris-catalog" && node.connectionId) { await loadDorisCatalogDatabases(node, options); } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { if (node.catalog && node.catalog !== "internal") { await loadDorisCatalogTables(node, options); } else { const config = getConfig(node.connectionId); const effectiveDbType = effectiveDatabaseTypeForConnection(config); if (config?.db_type === "sqlserver") { await loadSqlServerDatabaseObjects(node.connectionId, node.database, options); } else if ((usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) || connectionShouldDiscoverJdbcSchemas(config)) { await loadSchemas(node.connectionId, node.database, options); } else { await loadTables(node.connectionId, node.database, undefined, options); } } } else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await loadTables(node.connectionId, node.database, node.schema, options); } else if (node.type === "linked-server-root" && node.connectionId) { await loadSqlServerLinkedServers(node.connectionId, options); } else if (node.type === "linked-server" && node.connectionId) { await loadSqlServerLinkedServerCatalogs(node, options); } else if (node.type === "linked-server-catalog" && node.connectionId) { await loadSqlServerLinkedServerSchemas(node, options); } else if (node.type === "linked-server-schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { await loadTables(node.connectionId, node.database, node.schema, options); } else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) { await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog); } else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-indexes" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-constraints" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadConstraints(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-table-partitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadPartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-table-subpartitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loadSubpartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (objectTypesForGroupNode(node.type)) { await loadObjectGroupChildren(node, options); } else if (node.type === "group-partitions") { node.isExpanded = true; } else if (node.type === "group-extensions" && node.connectionId && hasTreeNodeDatabaseContext(node)) { await loadExtensions(node.connectionId, node.database || ""); } } async function restoreExpandedChildren(node: TreeNode, expandedIds: Set, options?: LoadTreeOptions, isCurrent: () => boolean = () => true) { if (!isCurrent()) return; if (!node.children) return; for (const child of node.children) { if (!isCurrent()) return; if (!expandedIds.has(child.id)) continue; await loadTreeNodeChildren(child, options); if (!isCurrent()) return; await restoreExpandedChildren(child, expandedIds, options, isCurrent); } } async function refreshTreeNode(node: TreeNode) { invalidateMetadataCachesForNode(node); if (objectTypesForGroupNode(node.type)) { clearLoadedChildrenCache(node.id, { deletePersisted: false }); await loadObjectGroupChildren(node, { force: true }); return; } const parentId = objectGroupRefreshParentId(node); const parentNode = parentId ? findNode(treeNodes.value, parentId) : null; if (parentNode) { await refreshTreeNode(parentNode); return; } if (node.connectionId && !connectedIds.value.has(node.connectionId)) return; const expandedIds = collectExpandedNodeIds([node]); expandedIds.add(node.id); const previousChildren = node.children; const previousHiddenChildren = node.hiddenChildren; const previousObjectCount = node.objectCount; const previousExpanded = node.isExpanded; const previousLoadedIds = [...loadedTreeNodeChildrenIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`)); const previousConfirmedEmptyIds = [...confirmedEmptyTreeNodeIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`)); const connectionRevision = node.connectionId ? connectionStateRevision(node.connectionId) : undefined; const refreshGeneration = ++nextTreeRefreshGeneration; activeTreeRefreshGenerations.set(node.id, refreshGeneration); const ownsRefreshGeneration = () => activeTreeRefreshGenerations.get(node.id) === refreshGeneration; const isCurrentRefresh = () => ownsRefreshGeneration() && (!node.connectionId || connectionStateRevision(node.connectionId) === connectionRevision); try { await clearPersistedTreeCacheForNode(node); if (!isCurrentRefresh()) return; clearLoadedChildrenCache(node.id); if (node.type !== "connection-group") { node.children = []; } await loadTreeNodeChildren(node, { force: true }); if (isCurrentRefresh()) { await restoreExpandedChildren(node, expandedIds, { force: true }, isCurrentRefresh); } } catch (error) { // A stale failure must never overwrite a newer successful (including empty) result. if (isCurrentRefresh()) { const target = treeNodeInSidebarTree(node); if (target) { target.children = previousChildren; target.hiddenChildren = previousHiddenChildren; target.objectCount = previousObjectCount; target.isExpanded = previousExpanded; clearLoadedChildrenCache(target.id, { deletePersisted: false }); for (const id of previousLoadedIds) loadedTreeNodeChildrenIds.value.add(id); for (const id of previousConfirmedEmptyIds) confirmedEmptyTreeNodeIds.value.add(id); } } throw error; } finally { if (ownsRefreshGeneration()) { activeTreeRefreshGenerations.delete(node.id); } } } async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) { invalidateMetadataCachesForNode(node); if (objectTypesForGroupNode(node.type)) { clearLoadedChildrenCache(node.id); await loadObjectGroupChildren(node, { force: true, tableNameFilterScopeKey: scopeKey, expectedTableNameFilterRevision: revision, }); return; } await refreshTreeNode(node); } async function refreshDatabaseTreeNode(connectionId: string, database: string, catalog?: string) { const node = findDatabaseTreeNode(treeNodes.value, connectionId, database, catalog); if (node) { await refreshTreeNode(node); return; } if (catalog) { const catalogNode = findNode(treeNodes.value, dorisCatalogId(connectionId, catalog)); if (catalogNode) { await refreshTreeNode(catalogNode); return; } } await loadDatabases(connectionId, { force: true }); } async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string, catalog?: string) { const match = { connectionId, database, schema }; invalidateMetadataCaches(match); void invalidateObjectDdlCache(match); const shouldRefreshSchemaNode = !!schema && !catalog; const node = shouldRefreshSchemaNode ? findNode(treeNodes.value, `${connectionId}:${database}:${schema}`) : null; if (node) { await refreshTreeNode(node); } else { await refreshDatabaseTreeNode(connectionId, database, catalog); } void loadSidebarTableStorage({ connectionId, database, schema: schema || "" }, { force: true }); } function isSchemaAwareDatabase(connectionId: string): boolean { return isSchemaAware(getConfig(connectionId)?.db_type); } function isPostgresLikeForExtensions(dbType?: string): boolean { return dbType === "postgres" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss" || dbType === "highgo" || dbType === "uxdb" || dbType === "vastbase" || dbType === "kingbase"; } function metadataQuerySchema(connectionId: string, database: string, schema?: string): string { return connectionObjectTreeQuerySchema(getConfig(connectionId), database, schema); } const COMPLETION_CACHE_MAX = 50; function evictOldestCacheEntries(cache: Record, max: number) { const keys = Object.keys(cache); if (keys.length <= max) return; const toRemove = keys.slice(0, keys.length - max); for (const key of toRemove) { delete cache[key]; } } function completionScopeKey(connectionId: string, database: string, schema?: string): string { return `${connectionId}:${database}:${schema?.toLowerCase() ?? ""}`; } function completionTableScopeKey(connectionId: string, database: string, schema?: string, catalog?: string): string { return `${connectionId}:${database}:${catalog?.toLowerCase() ?? ""}:${schema?.toLowerCase() ?? ""}`; } function completionColumnsKey(connectionId: string, database: string, table: string, schema?: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): string { if (getConfig(connectionId)?.db_type === "oracle") { const normalizedTable = context?.tableQuoted === false ? table.toUpperCase() : table; const normalizedSchema = schema && context?.schemaQuoted === false ? schema.toUpperCase() : (schema ?? ""); return `${connectionId}:${database}:${catalog ?? ""}:${normalizedSchema}:${normalizedTable}`; } return `${completionTableScopeKey(connectionId, database, schema, catalog)}:${table.toLowerCase()}`; } function completionForeignKeysKey(connectionId: string, database: string, table: string, schema?: string): string { return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}:fkeys`; } function completionTableCacheKeyMatches(key: string, connectionId: string, database: string, tableName: string, schema?: string, catalog?: string): boolean { const normalizedKey = key.toLowerCase(); const prefix = `${connectionId}:${database}:`.toLowerCase(); if (!normalizedKey.startsWith(prefix)) return false; const tableToken = `:${tableName.toLowerCase()}`; const tableOffset = normalizedKey.lastIndexOf(tableToken); if (tableOffset < prefix.length) return false; const trailing = normalizedKey.slice(tableOffset + tableToken.length); if (trailing && !trailing.startsWith(":")) return false; const normalizedSchema = schema?.trim().toLowerCase(); const normalizedCatalog = catalog?.trim().toLowerCase(); const scope = normalizedKey.slice(prefix.length, tableOffset); if (normalizedCatalog) { const catalogScope = `${normalizedCatalog}:${normalizedSchema ?? ""}`; return scope === catalogScope || (!!normalizedSchema && scope === normalizedSchema); } if (!normalizedSchema) return true; return scope === normalizedSchema || scope.endsWith(`:${normalizedSchema}`); } function invalidateCompletionTableCache(connectionId: string, database: string, tableName: string, schema?: string, catalog?: string): number { const matches = (key: string) => completionTableCacheKeyMatches(key, connectionId, database, tableName, schema, catalog); let removed = 0; for (const cache of [completionColumnsCache.value, completionForeignKeysCache.value]) { for (const key of Object.keys(cache)) { if (!matches(key)) continue; delete cache[key]; removed++; } } for (const cache of [completionColumnIndex, completionForeignKeyIndex, completionInFlight]) { for (const key of cache.keys()) { if (!matches(key)) continue; cache.delete(key); removed++; } } return removed; } function touchCompletionIndex(index: Map, key: string, value: T, max = COMPLETION_CACHE_MAX) { index.set(key, { ...value, touched: Date.now() }); if (index.size <= max) return; const oldest = [...index.entries()].sort(([, a], [, b]) => a.touched - b.touched).slice(0, index.size - max); for (const [oldKey] of oldest) index.delete(oldKey); } function completionLimiterScope(connectionId: string, database = ""): string { return `${connectionId}:${database}`; } function withCompletionInFlight(key: string, load: () => Promise, limit?: { scope: string; kind: string }): Promise { const existing = completionInFlight.get(key) as Promise | undefined; if (existing) return existing; const promise = (limit ? completionMetadataLimiter.run(limit.scope, limit.kind, load) : load()).finally(() => { if (completionInFlight.get(key) === promise) completionInFlight.delete(key); }); completionInFlight.set(key, promise); return promise; } function completionAssistantRequestKey(request: CompletionAssistantRequest): string { return JSON.stringify({ connection_id: request.connection_id, database: request.database, schema: request.schema ?? "", object_kinds: [...(request.object_kinds ?? [])].sort(), mask: request.mask ?? "", case_sensitive: !!request.case_sensitive, global_search: !!request.global_search, max_results: request.max_results ?? null, search_in_comments: !!request.search_in_comments, search_in_definitions: !!request.search_in_definitions, parent_schema: request.parent_schema ?? "", parent_name: request.parent_name ?? "", match_mode: request.match_mode ?? "prefix", }); } async function completionAssistantSearch(request: CompletionAssistantRequest) { return withCompletionInFlight(`assistant:${completionAssistantRequestKey(request)}`, async () => { await ensureConnected(request.connection_id); return api.completionAssistantSearch(request); }); } async function loadPackageMembers(node: TreeNode, options?: LoadTreeOptions): Promise { if (node.type !== "package" || !node.connectionId || !node.database) return; if (!supportsPackageMemberExpansion(effectiveDatabaseTypeForConnection(getConfig(node.connectionId)))) return; const connectionId = node.connectionId; const database = node.database; const schema = node.schema; const packageName = node.objectName || node.label; let load = beginTreeNodeLoad(node); try { await runTreeMetadataLoad( { kind: "package-members", connectionId, database, schema, nodeKind: node.type, extra: { packageName }, }, async () => { await ensureConnected(connectionId); load = reclaimTreeNodeLoad(load, node); const response = await completionAssistantSearch({ connection_id: connectionId, database, schema: schema ?? null, object_kinds: ["routine"], mask: "", case_sensitive: true, global_search: false, max_results: 1000, search_in_comments: false, search_in_definitions: false, parent_schema: schema ?? null, parent_name: packageName, match_mode: "prefix", }); const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; setChildren(targetNode, buildPackageMemberNodes(targetNode, response.candidates)); targetNode.isExpanded = true; }, options, ); } catch (error) { recordMetadataLoadError(connectionId, error, load); throw error; } finally { finishTreeNodeLoad(load); } } const ORACLE_SYSTEM_COMPLETION_SCHEMAS = new Set(["SYS", "SYSTEM", "SYSMAN", "DBSNMP", "OUTLN", "XDB", "MDSYS", "CTXSYS", "WMSYS"]); const FILTERED_ROUTINE_COMPLETION_DATABASES = new Set(["mysql", "postgres", "sqlserver", "oracle"]); function completionPreferredSchema(connectionId: string, preferredSchema?: string): string | undefined { return preferredSchema?.trim() || getConfig(connectionId)?.username?.trim() || undefined; } function completionCandidateSchemaBoost(schema: string | null | undefined, preferredSchema?: string): number { if (schema && preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase()) return 2400; if (schema?.toUpperCase() === "PUBLIC") return 1200; if (schema && ORACLE_SYSTEM_COMPLETION_SCHEMAS.has(schema.toUpperCase())) return -1200; return 0; } function completionCandidateApplyName(name: string, schema: string | null | undefined, preferredSchema?: string): string { if (!schema || schema.toUpperCase() === "PUBLIC" || (preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase())) return name; return `${schema}.${name}`; } function completionAssistantTables(candidates: CompletionAssistantCandidate[], preferredSchema?: string, withOracleMetadata = false): SqlCompletionTable[] { return candidates .filter((candidate) => candidate.kind === "table" || candidate.kind === "view") .map((candidate) => { const table: SqlCompletionTable = { name: candidate.name, schema: candidate.schema ?? undefined, type: sqlObjectNavigationTypeFromTableType(candidate.data_type || candidate.kind), ...completionStableTableType(candidate.data_type), }; if (!withOracleMetadata) return table; return { ...table, detail: candidate.schema ? `${candidate.schema} · ${(candidate.data_type || candidate.kind).toLowerCase()}` : candidate.kind, applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema), boost: completionCandidateSchemaBoost(candidate.schema, preferredSchema), }; }); } function completionAssistantObjects(candidates: CompletionAssistantCandidate[], preferredSchema?: string, oracleMetadata = false): SqlCompletionObject[] { return candidates .map((candidate): SqlCompletionObject | null => { const candidateType = candidate.data_type?.toUpperCase(); const type = candidate.kind === "procedure" ? "procedure" : candidate.kind === "function" ? "function" : candidate.kind === "object" && candidateType === "PACKAGE" ? "package" : null; if (!type) return null; const dataType = candidate.data_type && !["FUNCTION", "PROCEDURE", "PACKAGE"].includes(candidateType ?? "") ? candidate.data_type : undefined; return { name: candidate.name, schema: candidate.schema ?? undefined, type, parentSchema: candidate.parent_schema ?? undefined, parentName: candidate.parent_name ?? undefined, dataType, signature: candidate.signature ?? undefined, comment: candidate.comment ?? null, applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema), boost: oracleMetadata ? completionCandidateSchemaBoost(candidate.schema, preferredSchema) : completionRoutineSchemaBoost(candidate.schema, preferredSchema), }; }) .filter((object): object is SqlCompletionObject => object != null); } function completionRoutineSchemaBoost(schema: string | null | undefined, preferredSchema?: string): number { if (schema && preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase()) return 1000; if (schema?.toUpperCase() === "PUBLIC") return 600; return 0; } function completionAssistantIdentifierMatches(candidate: string, requested: string, quoted?: boolean): boolean { return quoted ? candidate === requested : candidate.toLowerCase() === requested.toLowerCase(); } function completionAssistantColumns(candidates: CompletionAssistantCandidate[], table: string, schema?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): SqlCompletionColumn[] { const requestedSchema = schema?.trim(); return candidates .filter((candidate) => { if (candidate.kind !== "column") return false; const parentName = candidate.parent_name?.trim(); if (parentName && !completionAssistantIdentifierMatches(parentName, table, context?.tableQuoted)) return false; const parentSchema = candidate.parent_schema?.trim() || candidate.schema?.trim(); if (requestedSchema && parentSchema && !completionAssistantIdentifierMatches(parentSchema, requestedSchema, context?.schemaQuoted)) return false; return true; }) .map((candidate) => ({ name: candidate.name, table: candidate.parent_name ?? table, schema: candidate.parent_schema ?? candidate.schema ?? schema, dataType: candidate.data_type ?? undefined, comment: candidate.comment ?? null, })); } async function listCompletionAssistantTables(connectionId: string, database: string, filter: string, limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise { const oracleAssistant = getConfig(connectionId)?.db_type === "oracle"; const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, globalSearch ? currentSchema : (schema ?? currentSchema)) : schema?.trim() || undefined; const objectKinds: CompletionAssistantObjectKind[] = ["table", "view"]; const response = await completionAssistantSearch({ connection_id: connectionId, database, schema: preferredSchema ?? null, object_kinds: objectKinds, mask: filter.trim(), max_results: limit ?? 200, global_search: globalSearch, parent_schema: globalSearch ? null : (schema ?? null), match_mode: "prefix", }); const tables = completionAssistantTables(response.candidates, preferredSchema, oracleAssistant); indexCompletionTables(connectionId, database, schema, tables); return tables; } async function listCompletionAssistantObjects( connectionId: string, database: string, filter: string, limit: number | undefined, schema: string | undefined, parentName: string | undefined, globalSearch: boolean, currentSchema: string | undefined, objectKinds: CompletionAssistantObjectKind[], ): Promise { const databaseType = getConfig(connectionId)?.db_type; const oracleAssistant = databaseType === "oracle"; const requestedSchema = schema?.trim() || currentSchema?.trim() || undefined; const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, currentSchema) : requestedSchema || (databaseType === "postgres" ? "public" : databaseType === "mysql" ? database : undefined); const response = await completionAssistantSearch({ connection_id: connectionId, database, schema: oracleAssistant ? (preferredSchema ?? null) : (requestedSchema ?? null), object_kinds: objectKinds, mask: filter.trim(), max_results: limit ?? 200, global_search: globalSearch, parent_schema: globalSearch ? null : (schema ?? null), parent_name: parentName ?? null, match_mode: "prefix", }); const objects = completionAssistantObjects(response.candidates, preferredSchema, oracleAssistant).map((object) => ({ ...object, applyName: databaseType === "sqlserver" && object.schema ? `${object.schema}.${object.name}` : object.applyName, })); indexCompletionObjects(connectionId, database, schema, objects); return objects; } async function listCompletionAssistantColumns(connectionId: string, database: string, table: string, schema?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): Promise { const response = await completionAssistantSearch({ connection_id: connectionId, database, schema: schema ?? null, object_kinds: ["column"], mask: "", max_results: 500, parent_schema: schema ?? null, parent_name: table, match_mode: "prefix", }); const columns = completionAssistantColumns(response.candidates, table, schema, context); if (columns.length > 0) indexCompletionColumns(connectionId, database, table, schema, columns); return columns; } function completionNameSegments(name: string): string[] { return name .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .split(/[\s_.:-]+/) .map((segment) => segment.trim().toLowerCase()) .filter(Boolean); } function completionNameAcronym(name: string): string { return completionNameSegments(name) .map((segment) => segment[0]) .join(""); } function orderedSubsequenceScore(text: string, filter: string): number { let index = 0; let gaps = 0; for (const ch of filter) { const found = text.indexOf(ch, index); if (found < 0) return -1; gaps += found - index; index = found + 1; } return 1_000 - gaps - text.length; } function tableMatchScore(table: SqlCompletionTable, filter: string, preferredSchema?: string): number { const text = table.name.toLowerCase(); const schema = table.schema?.toLowerCase(); const normalized = filter.trim().toLowerCase(); let score = schema && preferredSchema && schema === preferredSchema.toLowerCase() ? 10_000 : 0; if (!normalized) return score; if (text === normalized) return score + 9_000 - text.length; if (text.startsWith(normalized)) return score + 7_500 - text.length; const segments = completionNameSegments(table.name); if (segments.some((segment) => segment.startsWith(normalized))) return score + 7_200 - text.length; const acronym = completionNameAcronym(table.name); if (acronym === normalized) return score + 7_100 - text.length; if (acronym.startsWith(normalized)) return score + 6_900 - text.length; // DataGrip-style pinyin initials for Han names, e.g. "zzj" → 总租金, // including ordered subsequences like "zj" → 总租金. if (/^[a-z0-9]+$/.test(normalized) && containsHan(text)) { const pinyinInitials = pinyinFirstLetters(text); if (pinyinInitials === normalized) return score + 7_050 - text.length; if (pinyinInitials.startsWith(normalized)) return score + 6_850 - text.length; const subsequence = orderedSubsequenceSpan(pinyinInitials, normalized); if (subsequence) return score + 5_000 - subsequence.first * 30 - subsequence.span * 10 - text.length; } if (normalized.length <= segments.length && segments.every((segment, index) => segment.startsWith(normalized[index] ?? ""))) return score + 6_700 - text.length; if (text.includes(normalized)) return score + 4_000 - text.length; const subsequenceScore = orderedSubsequenceScore(text, normalized); return subsequenceScore < 0 ? -1 : score + subsequenceScore; } function objectMatchScore(object: SqlCompletionObject, filter: string, preferredSchema?: string): number { const tableLike: SqlCompletionTable = { name: object.name, schema: object.schema }; return tableMatchScore(tableLike, filter, preferredSchema); } function indexCompletionTables(connectionId: string, database: string, schema: string | undefined, tables: SqlCompletionTable[], catalog?: string) { const groups = new Map(); for (const table of tables) { const tableSchema = table.schema ?? schema; const tableCatalog = table.catalog ?? catalog; const key = completionTableScopeKey(connectionId, database, tableSchema, tableCatalog); const list = groups.get(key) ?? []; list.push({ ...table, catalog: tableCatalog, schema: tableSchema }); groups.set(key, list); } for (const [key, group] of groups) { const previous = completionTableIndex.get(key)?.tables ?? []; touchCompletionIndex(completionTableIndex, key, { tables: dedupeCompletionTables([...previous, ...group]), }); } } function indexCompletionObjects(connectionId: string, database: string, schema: string | undefined, objects: SqlCompletionObject[]) { const groups = new Map(); for (const object of objects) { const objectSchema = object.schema ?? schema; const key = completionScopeKey(connectionId, database, objectSchema); const list = groups.get(key) ?? []; list.push({ ...object, schema: objectSchema }); groups.set(key, list); } for (const [key, group] of groups) { const previous = completionObjectIndex.get(key)?.objects ?? []; touchCompletionIndex(completionObjectIndex, key, { objects: dedupeCompletionObjects([...previous, ...group]), }); } } function indexCompletionColumns(connectionId: string, database: string, table: string, schema: string | undefined, columns: SqlCompletionColumn[], catalog?: string) { touchCompletionIndex(completionColumnIndex, completionColumnsKey(connectionId, database, table, schema, catalog), { columns, }); } function sqlCompletionForeignKeys(foreignKeys: ForeignKeyInfo[]): SqlCompletionForeignKey[] { return foreignKeys.map((foreignKey) => ({ name: foreignKey.name, column: foreignKey.column, ref_schema: foreignKey.ref_schema, ref_table: foreignKey.ref_table, ref_column: foreignKey.ref_column, })); } function indexCompletionForeignKeys(connectionId: string, database: string, table: string, schema: string | undefined, foreignKeys: SqlCompletionForeignKey[]) { touchCompletionIndex(completionForeignKeyIndex, completionForeignKeysKey(connectionId, database, table, schema), { foreignKeys, }); } function lookupLocalCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, catalog?: string): SqlCompletionTable[] { const scopePrefix = `${connectionId}:${database}:${catalog?.toLowerCase() ?? ""}:`; const allScopes = [...completionTableIndex.entries()].filter(([key]) => key.startsWith(scopePrefix)).map(([, entry]) => entry); const preferred = schema ? completionTableIndex.get(completionTableScopeKey(connectionId, database, schema, catalog)) : undefined; const scopes = schema ? (preferred ? [preferred] : []) : allScopes; const treeTables = completionTablesFromTree(treeNodes.value, connectionId, database, schema, catalog); const ranked = scopes .flatMap((entry) => entry?.tables ?? []) .concat(treeTables) .map((table) => ({ table, score: tableMatchScore(table, filter, schema) })) .filter((entry) => entry.score >= 0) .sort((a, b) => b.score - a.score || a.table.name.localeCompare(b.table.name)); return dedupeCompletionTables(ranked.map((entry) => entry.table)).slice(0, limit ?? 200); } function lookupLocalCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string): SqlCompletionObject[] { const allScopes = [...completionObjectIndex.entries()].filter(([key]) => key.startsWith(`${connectionId}:${database}:`)).map(([, entry]) => entry); const preferred = schema ? completionObjectIndex.get(completionScopeKey(connectionId, database, schema)) : undefined; const scopes = schema ? (preferred ? [preferred] : []) : allScopes; const ranked = scopes .flatMap((entry) => entry?.objects ?? []) .map((object) => ({ object, score: objectMatchScore(object, filter, schema) })) .filter((entry) => entry.score >= 0) .sort((a, b) => b.score - a.score || a.object.name.localeCompare(b.object.name)); return dedupeCompletionObjects(ranked.map((entry) => entry.object)).slice(0, limit ?? 200); } function lookupLocalCompletionSchemas(connectionId: string, database: string, filter = "", limit = 50): string[] { const schemas = dedupeCompletionQualifierNames([...(schemaListCache.value[`${connectionId}:${database}`] ?? []), ...completionSchemasFromTree(treeNodes.value, connectionId, database)]); const normalized = filter.trim().toLowerCase(); return schemas .filter((schema) => fuzzyTextMatch(schema, normalized)) .sort((a, b) => tableMatchScore({ name: b }, normalized) - tableMatchScore({ name: a }, normalized)) .slice(0, limit); } function lookupLocalCompletionDatabases(connectionId: string, filter = "", limit = 50): string[] { const databases = completionDatabasesCache.value[connectionId] ?? databaseNamesFromTree(connectionId); const normalized = filter.trim().toLowerCase(); return databases .filter((database) => fuzzyTextMatch(database, normalized)) .sort((a, b) => tableMatchScore({ name: b }, normalized) - tableMatchScore({ name: a }, normalized)) .slice(0, limit); } function dedupeCompletionQualifierNames(names: string[]): string[] { const seen = new Set(); const result: string[] = []; for (const name of names) { const normalized = name.trim(); if (!normalized) continue; const key = normalized.toLowerCase(); if (seen.has(key)) continue; seen.add(key); result.push(normalized); } return result; } function lookupLocalCompletionColumns(connectionId: string, database: string, table: string, schema?: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): SqlCompletionColumn[] { return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema, catalog, context))?.columns ?? []; } function lookupLocalCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): SqlCompletionForeignKey[] { return completionForeignKeyIndex.get(completionForeignKeysKey(connectionId, database, table, schema))?.foreignKeys ?? []; } function databaseNamesFromTree(connectionId: string): string[] { const node = findConnectionNode(connectionId); if (!node?.children) return []; const seen = new Set(); const names: string[] = []; for (const child of node.children) { if (child.type !== "database" || !child.database) continue; const key = child.database.toLowerCase(); if (seen.has(key)) continue; seen.add(key); names.push(child.database); } return names; } async function listCompletionDatabases(connectionId: string): Promise { if (completionDatabasesCache.value[connectionId]) { return completionDatabasesCache.value[connectionId]; } return withCompletionInFlight( `${connectionId}:completion-databases`, async () => { await ensureConnected(connectionId); const config = getConfig(connectionId); const databases = await api.listDatabases(connectionId); completionDatabasesCache.value[connectionId] = filterDatabaseNamesForConnection( databases.map((database) => database.name), config, ); evictOldestCacheEntries(completionDatabasesCache.value, COMPLETION_CACHE_MAX); return completionDatabasesCache.value[connectionId]; }, { scope: completionLimiterScope(connectionId), kind: "databases" }, ); } async function getSqlServerCompletionContext(connectionId: string, database: string): Promise { const cacheKey = `${connectionId}:${database}`; if (sqlServerCompletionContextCache.value[cacheKey]) { return sqlServerCompletionContextCache.value[cacheKey]; } return withCompletionInFlight(`${cacheKey}:sqlserver-completion-context`, async () => { await ensureConnected(connectionId); const context = await api.getSqlServerCompletionContext(connectionId, database); sqlServerCompletionContextCache.value[cacheKey] = context; evictOldestCacheEntries(sqlServerCompletionContextCache.value, COMPLETION_CACHE_MAX); return context; }); } async function listCompletionSchemas(connectionId: string, database: string): Promise { const cacheKey = `${connectionId}:${database}`; if (schemaListCache.value[cacheKey]) { return schemaListCache.value[cacheKey]; } return withCompletionInFlight(`${cacheKey}:schemas`, async () => { const schemas = await api.listSchemas(connectionId, database); schemaListCache.value[cacheKey] = schemas; evictOldestCacheEntries(schemaListCache.value, COMPLETION_CACHE_MAX); return schemas; }); } async function listElasticsearchCompletionIndices(connectionId: string, database: string): Promise { const cacheKey = `${connectionId}:${database}`; if (elasticsearchCompletionIndicesCache.value[cacheKey]) { return elasticsearchCompletionIndicesCache.value[cacheKey]; } await ensureConnected(connectionId); const indices = await api.elasticsearchListIndices(connectionId); elasticsearchCompletionIndicesCache.value[cacheKey] = indices; evictOldestCacheEntries(elasticsearchCompletionIndicesCache.value, COMPLETION_CACHE_MAX); return elasticsearchCompletionIndicesCache.value[cacheKey]; } // Upper bound on cached key names per db, to keep completion memory bounded // (Redis can hold far more keys than we ever want resident for autocomplete). const REDIS_COMPLETION_KEYS_MAX = 1000; async function listRedisCompletionKeys(connectionId: string, database: string): Promise { if (!database) return []; const cacheKey = `${connectionId}:${database}`; const cached = redisCompletionKeysCache.value[cacheKey]; if (cached) return cached; return withCompletionInFlight(`${cacheKey}:redis-keys`, async () => { await ensureConnected(connectionId); const pageSize = getConfig(connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT; // Bounded multi-round SCAN: trade coverage for latency/memory safety. const result = await api.redisScanKeysBatch(connectionId, Number(database), 0, "*", pageSize, 6, false); const keys = result.keys.map((key) => key.key_display).slice(0, REDIS_COMPLETION_KEYS_MAX); redisCompletionKeysCache.value[cacheKey] = keys; evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX); return keys; }); } async function listMongoCompletionCollections(connectionId: string, database: string): Promise { if (!database) return []; const cacheKey = `${connectionId}:${database}`; const cached = mongoCompletionCollectionsCache.value[cacheKey]; if (cached) return cached; return withCompletionInFlight(`${cacheKey}:mongo-collections`, async () => { await ensureConnected(connectionId); const collections = sortSidebarNames((await api.mongoListCollections(connectionId, database)).map((c) => c.name)); mongoCompletionCollectionsCache.value[cacheKey] = collections; evictOldestCacheEntries(mongoCompletionCollectionsCache.value, COMPLETION_CACHE_MAX); return collections; }); } async function listMongoCompletionFields(connectionId: string, database: string, collection: string): Promise { if (!database || !collection) return []; const cacheKey = `${connectionId}:${database}:${collection}`; const cached = mongoCompletionFieldsCache.value[cacheKey]; if (cached) return cached; return withCompletionInFlight(`${cacheKey}:mongo-fields`, async () => { await ensureConnected(connectionId); const result = await api.mongoFindDocuments(connectionId, database, collection, 0, 20, "{}"); const fields = inferMongoCompletionFields(result.documents ?? []); mongoCompletionFieldsCache.value[cacheKey] = fields; evictOldestCacheEntries(mongoCompletionFieldsCache.value, COMPLETION_CACHE_MAX); return fields; }); } function listCompletionTableMetadata(connectionId: string, database: string, schema: string, filter?: string, limit?: number, catalog?: string): Promise { if (catalog) return api.listTables(connectionId, database, schema, filter, limit, undefined, undefined, catalog); return api.listTables(connectionId, database, schema, filter, limit); } async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string, catalog?: string, options: { activateConnection?: boolean } = {}): Promise { const trimmedFilter = filter.trim(); const normalizedFilter = trimmedFilter.toLowerCase(); // Remote queries (Dameng/Oracle) are case-sensitive, so the cache key must // preserve original casing — otherwise "TEST" and "test" collide and the // second lookup returns the first's stale results. Local lookups below stay // case-insensitive because tableMatchScore normalizes internally. const relaxedFilter = relaxedCompletionTableFilter(trimmedFilter); const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${trimmedFilter}:${limit ?? ""}:${schema ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}`; if (completionTablesCache.value[cacheKey]) { return completionTablesCache.value[cacheKey]; } return withCompletionInFlight( `${cacheKey}:tables`, async () => { await ensureConnected(connectionId, { activate: options.activateConnection !== false }); if (isSchemaAwareDatabase(connectionId)) { if (normalizedFilter || limit) { let results: SqlCompletionTable[] = []; try { results = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema); } catch { if (schema) { const tables = await listCompletionTableMetadata(connectionId, database, schema, trimmedFilter, limit, catalog); results = tables.map((table) => ({ name: table.name, catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), ...completionStableTableType(table.table_type), })); } else { results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog); } } if (results.length === 0 && relaxedFilter) { if (globalSearch) { try { results = await listCompletionAssistantTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), schema, true, currentSchema); } catch { results = []; } } else if (schema) { try { const tables = await listCompletionTableMetadata(connectionId, database, schema, relaxedFilter, expandedCompletionLimit(limit), catalog); results = tables.map((table) => ({ name: table.name, catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), ...completionStableTableType(table.table_type), })); } catch { results = []; } } else { results = lookupLocalCompletionTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), undefined, catalog); } } const limitedTables = limit ? dedupeCompletionTables(results).slice(0, limit) : results; completionTablesCache.value[cacheKey] = limitedTables; indexCompletionTables(connectionId, database, undefined, limitedTables, catalog); evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; } if (schema) { const tables = await listCompletionTableMetadata(connectionId, database, schema, undefined, undefined, catalog); completionTablesCache.value[cacheKey] = tables.map((table) => ({ name: table.name, catalog, schema, type: sqlObjectNavigationTypeFromTableType(table.table_type), ...completionStableTableType(table.table_type), })); } else { completionTablesCache.value[cacheKey] = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog); } indexCompletionTables(connectionId, database, undefined, completionTablesCache.value[cacheKey], catalog); evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; } const querySchema = catalog ? "" : database; let tables = await listCompletionTableMetadata(connectionId, database, querySchema, trimmedFilter, limit, catalog); if (tables.length === 0 && relaxedFilter) { tables = await listCompletionTableMetadata(connectionId, database, querySchema, relaxedFilter, expandedCompletionLimit(limit), catalog); } completionTablesCache.value[cacheKey] = tables.map((table) => ({ name: table.name, catalog, type: sqlObjectNavigationTypeFromTableType(table.table_type), ...completionStableTableType(table.table_type), })); completionTablesCache.value[cacheKey] = limit ? completionTablesCache.value[cacheKey].slice(0, limit) : completionTablesCache.value[cacheKey]; indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey], catalog); evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX); return completionTablesCache.value[cacheKey]; }, { scope: completionLimiterScope(connectionId, database), kind: "tables" }, ); } function relaxedCompletionTableFilter(filter: string): string | undefined { if (filter.length < 3) return undefined; return filter.slice(0, 2); } function expandedCompletionLimit(limit?: number): number | undefined { if (!limit) return limit; return Math.min(Math.max(limit * 3, limit), 1000); } function dedupeCompletionTables(tables: SqlCompletionTable[]): SqlCompletionTable[] { const indexByKey = new Map(); const deduped: SqlCompletionTable[] = []; for (const table of tables) { const key = `${table.catalog ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase(); const existingIndex = indexByKey.get(key); if (existingIndex != null) { const existing = deduped[existingIndex]; // Loaded tree metadata can distinguish materialized views even when an older completion endpoint only reports VIEW. deduped[existingIndex] = { ...table, ...existing, type: mergeSqlObjectNavigationType(existing.type, table.type) }; continue; } indexByKey.set(key, deduped.length); deduped.push(table); } return deduped; } async function listCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string, parentName?: string, globalSearch = false, currentSchema?: string, objectKinds: CompletionAssistantObjectKind[] = ["routine"]): Promise { const normalizedFilter = filter.trim().toLowerCase(); const databaseType = getConfig(connectionId)?.db_type; const filteredRoutineAssistant = !!databaseType && FILTERED_ROUTINE_COMPLETION_DATABASES.has(databaseType) && (!!normalizedFilter || typeof limit === "number" || !!parentName || globalSearch); const cacheKey = filteredRoutineAssistant ? `${connectionId}:${database}:${schema ?? ""}:${parentName ?? ""}:${normalizedFilter}:${limit ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}:${[...objectKinds].sort().join(",")}` : `${connectionId}:${database}:${schema ?? ""}`; if (!completionObjectsCache.value[cacheKey]) { await withCompletionInFlight( `${cacheKey}:objects`, async () => { await ensureConnected(connectionId); if (filteredRoutineAssistant) { try { completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds)); } catch { const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database); completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null)); } } else { const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database); completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null)); } indexCompletionObjects(connectionId, database, schema, completionObjectsCache.value[cacheKey]); evictOldestCacheEntries(completionObjectsCache.value, COMPLETION_CACHE_MAX); }, { scope: completionLimiterScope(connectionId, database), kind: "objects" }, ); } const objects = completionObjectsCache.value[cacheKey]; const filtered = normalizedFilter ? objects.filter((object) => fuzzyCompletionObjectMatch(object, normalizedFilter)) : objects; return typeof limit === "number" ? filtered.slice(0, limit) : filtered; } async function listSchemaAwareCompletionObjects(connectionId: string, database: string, schema?: string): Promise { const schemas = schema ? [schema] : await listCompletionSchemas(connectionId, database); const batchSize = COMPLETION_METADATA_CONCURRENCY; const results: ObjectInfo[] = []; for (let i = 0; i < schemas.length; i += batchSize) { const batch = schemas.slice(i, i + batchSize); const groups = await Promise.all( batch.map(async (s) => { try { return await api.listCompletionObjects(connectionId, database, s); } catch { return [] as ObjectInfo[]; } }), ); for (const group of groups) results.push(...group); } return results; } function toSqlCompletionObject(object: ObjectInfo): SqlCompletionObject | null { const objectType = object.object_type.toUpperCase(); const type = objectType.includes("PROCEDURE") ? "procedure" : objectType.includes("FUNCTION") ? "function" : objectType.includes("TRIGGER") ? "trigger" : objectType.includes("PACKAGE") ? "package" : null; if (!type) return null; return { name: object.name, schema: object.schema ?? undefined, type, parentSchema: object.parent_schema ?? undefined, parentName: object.parent_name ?? undefined, signature: object.signature ?? undefined, comment: object.comment ?? null, }; } function fuzzyCompletionObjectMatch(object: SqlCompletionObject, filter: string): boolean { return fuzzyTextMatch(object.name, filter) || (!!object.schema && fuzzyTextMatch(object.schema, filter)) || (!!object.parentName && fuzzyTextMatch(object.parentName, filter)) || (!!object.parentSchema && fuzzyTextMatch(`${object.parentSchema}.${object.parentName ?? ""}`, filter)); } function fuzzyTextMatch(value: string, filter: string): boolean { if (!filter) return true; const text = value.toLowerCase(); if (text.includes(filter)) return true; // Pinyin initials, e.g. "zzj" or "zj" matches 总租金. if (/^[a-z0-9]+$/.test(filter) && containsHan(text)) { const pinyinInitials = pinyinFirstLetters(text); if (pinyinInitials.startsWith(filter) || orderedSubsequenceSpan(pinyinInitials, filter)) return true; } let index = 0; for (const ch of filter) { index = text.indexOf(ch, index); if (index < 0) return false; index++; } return true; } function dedupeCompletionObjects(objects: SqlCompletionObject[]): SqlCompletionObject[] { const seen = new Set(); const deduped: SqlCompletionObject[] = []; for (const object of objects) { const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}:${object.signature?.trim() ?? ""}`.toLowerCase(); if (seen.has(key)) continue; seen.add(key); deduped.push(object); } return deduped; } async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise { const config = getConfig(connectionId); const oracleIdentifier = config?.db_type === "oracle"; const uppercaseUnquotedIdentifier = oracleIdentifier || config?.db_type === "saphana"; const completionTable = uppercaseUnquotedIdentifier && context?.tableQuoted === false ? table.toUpperCase() : table; const rawCompletionSchema = schema?.trim() || (config?.db_type === "dameng" ? config.username?.trim() || undefined : undefined); const completionSchema = uppercaseUnquotedIdentifier && rawCompletionSchema && context?.schemaQuoted === false ? rawCompletionSchema.toUpperCase() : rawCompletionSchema; const usesOracleCurrentSchema = config?.db_type === "oracle" && !completionSchema; if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !completionSchema && !usesOracleCurrentSchema) { return []; } const sessionCacheScope = usesOracleCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : ""; const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${completionSchema || ""}:${completionTable}${sessionCacheScope}`; if (!completionColumnsCache.value[cacheKey]) { await withCompletionInFlight( `${cacheKey}:columns`, async () => { await ensureConnected(connectionId); if (!usesOracleCurrentSchema && !catalog) { try { const assistantColumns = await listCompletionAssistantColumns(connectionId, database, completionTable, completionSchema, context); if (assistantColumns.length > 0) { completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({ name: column.name, data_type: column.dataType ?? "", is_nullable: column.isNullable ?? true, column_default: null, is_primary_key: false, extra: null, comment: column.comment ?? null, numeric_precision: null, numeric_scale: null, character_maximum_length: null, })); evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX); return; } } catch { // Fall back to the existing metadata path below. } } const querySchema = usesOracleCurrentSchema ? "" : metadataQuerySchema(connectionId, database, completionSchema); completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, completionTable, catalog, usesOracleCurrentSchema ? context?.clientSessionId : undefined); evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX); }, { scope: completionLimiterScope(connectionId, database), kind: "columns" }, ); } const columns = completionColumnsCache.value[cacheKey].map((column) => ({ name: column.name, table: completionTable, schema: completionSchema, dataType: column.data_type, isNullable: column.is_nullable, comment: column.comment, })); if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, completionTable, completionSchema, columns, catalog); return columns; } async function listCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise { if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(getConfig(connectionId)) && !schema) { return []; } const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId))); if (!metadataCapabilities.foreignKeys) return []; const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`; if (!completionForeignKeysCache.value[cacheKey]) { await withCompletionInFlight( `${cacheKey}:fkeys`, async () => { await ensureConnected(connectionId); const querySchema = metadataQuerySchema(connectionId, database, schema); completionForeignKeysCache.value[cacheKey] = await api.listForeignKeys(connectionId, database, querySchema, table); evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX); }, { scope: completionLimiterScope(connectionId, database), kind: "foreignKeys" }, ); } const foreignKeys = sqlCompletionForeignKeys(completionForeignKeysCache.value[cacheKey]); indexCompletionForeignKeys(connectionId, database, table, schema, foreignKeys); return foreignKeys; } function refreshCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string, catalog?: string): Promise { return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog); } function refreshCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string, parentName?: string, globalSearch = false, currentSchema?: string): Promise { return listCompletionObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema); } function refreshCompletionSchemas(connectionId: string, database: string): Promise { return listCompletionSchemas(connectionId, database); } function refreshCompletionDatabases(connectionId: string): Promise { return listCompletionDatabases(connectionId); } function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise { return listCompletionColumns(connectionId, database, table, schema, context, catalog); } function refreshCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise { return listCompletionForeignKeys(connectionId, database, table, schema); } function findNode(nodes: TreeNode[], id: string): TreeNode | null { for (const node of nodes) { if (node.id === id) return node; if (node.children) { const found = findNode(node.children, id); if (found) return found; } } return null; } /** 查连接根节点:沿 connection-group 层级下钻但不穿透连接的整棵子树 * (原通用 DFS 找第 N 个连接前要完整遍历前 N-1 个连接的数千个表/列节点)。 * 不能用"同层优先"版 findNode 代替通用 DFS——节点 id 并非全树唯一 * (如数据库 "a:b" 与数据库 "a" 下 schema "b" 同为 connectionId:a:b, * 见 pinnedItems 对 colliding node IDs 的处理),改变遍历顺序会让深层 * 调用选中错误节点;连接根节点的 id 就是 connectionId,且只出现在 * 顶层或连接组内,无歧义。 */ function findConnectionNode(connectionId: string, nodes: TreeNode[] = treeNodes.value): TreeNode | null { for (const node of nodes) { if (node.id === connectionId && node.type !== "connection-group") return node; if (node.type === "connection-group" && node.children) { const found = findConnectionNode(connectionId, node.children); if (found) return found; } } return null; } async function persistConnections(nextConnections: ConnectionConfig[] = connections.value) { await api.saveConnections(nextConnections.filter((connection) => connection.one_time !== true)); } function sameIds(left: string[], right: string[]) { return left.length === right.length && left.every((id, index) => id === right[index]); } async function persistTimeoutInheritanceIds(connectIds: string[], queryIds: string[]) { if (sameIds(connectIds, settingsStore.editorSettings.connectTimeoutInheritConnectionIds) && sameIds(queryIds, settingsStore.editorSettings.queryTimeoutInheritConnectionIds)) return; settingsStore.updateEditorSettings({ connectTimeoutInheritConnectionIds: connectIds, queryTimeoutInheritConnectionIds: queryIds, }); await settingsStore.persistEditorSettings(); } async function persistTimeoutInheritance(connectionId: string, connectInherit: boolean, queryInherit: boolean) { const connectIds = new Set(settingsStore.editorSettings.connectTimeoutInheritConnectionIds); const queryIds = new Set(settingsStore.editorSettings.queryTimeoutInheritConnectionIds); if (connectInherit) connectIds.add(connectionId); else connectIds.delete(connectionId); if (queryInherit) queryIds.add(connectionId); else queryIds.delete(connectionId); await persistTimeoutInheritanceIds([...connectIds], [...queryIds]); } function syncTimeoutInheritanceBackup(source: ConnectionConfig[] = connections.value) { const connectSnapshots: Record = {}; const querySnapshots: Record = {}; for (const connection of source) { if (connection.connect_timeout_inherit === true) connectSnapshots[connection.id] = connection.connect_timeout_secs || settingsStore.editorSettings.globalConnectTimeoutSecs; if (connection.query_timeout_inherit === true) querySnapshots[connection.id] = connection.query_timeout_secs ?? settingsStore.editorSettings.globalQueryTimeoutSecs; } saveTimeoutInheritanceBackup({ version: 1, globalConnectTimeoutSecs: settingsStore.editorSettings.globalConnectTimeoutSecs, globalQueryTimeoutSecs: settingsStore.editorSettings.globalQueryTimeoutSecs, connectSnapshots, querySnapshots, }); } async function applyGlobalTimeouts({ connectTimeoutSecs, queryTimeoutSecs }: { connectTimeoutSecs?: number; queryTimeoutSecs?: number }) { const nextConnections = connections.value.map((connection) => { const nextConnectTimeout = connectTimeoutSecs !== undefined && connection.connect_timeout_inherit === true ? connectTimeoutSecs : connection.connect_timeout_secs; const nextQueryTimeout = queryTimeoutSecs !== undefined && connection.query_timeout_inherit === true ? queryTimeoutSecs : connection.query_timeout_secs; if (nextConnectTimeout === connection.connect_timeout_secs && nextQueryTimeout === connection.query_timeout_secs) return connection; return { ...connection, connect_timeout_secs: nextConnectTimeout, query_timeout_secs: nextQueryTimeout }; }); if (nextConnections.some((connection, index) => connection !== connections.value[index])) { await persistConnections(nextConnections); connections.value = nextConnections; } syncTimeoutInheritanceBackup(); } async function migrateTimeoutInheritance(saved: ConnectionConfig[]) { const migrationVersion = settingsStore.editorSettings.timeoutInheritanceMigrationVersion; const backup = loadTimeoutInheritanceBackup(); const connectIdsBefore = new Set(settingsStore.editorSettings.connectTimeoutInheritConnectionIds); const queryIdsBefore = new Set(settingsStore.editorSettings.queryTimeoutInheritConnectionIds); const globalConnectTimeoutSecs = migrationVersion < 2 && backup ? backup.globalConnectTimeoutSecs : settingsStore.editorSettings.globalConnectTimeoutSecs; const globalQueryTimeoutSecs = migrationVersion < 2 && backup ? backup.globalQueryTimeoutSecs : settingsStore.editorSettings.globalQueryTimeoutSecs; const resolveInheritance = (connection: ConnectionConfig, scope: "connect" | "query") => { const explicit = scope === "connect" ? connection.connect_timeout_inherit : connection.query_timeout_inherit; if (explicit === true || explicit === false) return explicit; const ids = scope === "connect" ? connectIdsBefore : queryIdsBefore; const snapshots = scope === "connect" ? backup?.connectSnapshots : backup?.querySnapshots; const value = Number(scope === "connect" ? (connection.connect_timeout_secs ?? 10) : (connection.query_timeout_secs ?? 30)); const snapshot = snapshots?.[connection.id]; if (snapshot !== undefined && (ids.has(connection.id) || migrationVersion < 2)) return value === snapshot; if (ids.has(connection.id)) return true; if (scope === "connect" && migrationVersion < 2) return value === 10; if (scope === "query" && migrationVersion < 1) return value === 30; return false; }; const connectIds = saved.filter((connection) => resolveInheritance(connection, "connect")).map((connection) => connection.id); const queryIds = saved.filter((connection) => resolveInheritance(connection, "query")).map((connection) => connection.id); settingsStore.updateEditorSettings({ globalConnectTimeoutSecs, connectTimeoutInheritConnectionIds: connectIds, globalQueryTimeoutSecs, queryTimeoutInheritConnectionIds: queryIds, timeoutInheritanceMigrationVersion: 2, }); if (migrationVersion !== 2 || !sameIds(connectIds, [...connectIdsBefore]) || !sameIds(queryIds, [...queryIdsBefore])) { await settingsStore.persistEditorSettings(); } } function persistSidebarLayoutDebounced() { if (layoutPersistTimer) clearTimeout(layoutPersistTimer); layoutPersistTimer = setTimeout(() => { api.saveSidebarLayout(sidebarLayout.value).catch(() => {}); layoutPersistTimer = null; }, 300); } function rebuildTreeNodes() { const existingNodesMap = new Map(); const collectExisting = (nodes: TreeNode[]) => { for (const node of nodes) { existingNodesMap.set(node.id, node); if (node.children) collectExisting(node.children); } }; collectExisting(treeNodes.value); const freshNodes = buildTreeNodesFromLayout(sidebarLayout.value, connections.value, pinnedTreeNodeIds.value); const mergeState = (nodes: TreeNode[]): TreeNode[] => nodes.map((node) => { const existing = existingNodesMap.get(node.id); if (node.type === "connection-group") { return inheritNaturalTreeNodeOrder(node, { ...node, children: mergeState(node.children || []) }); } if (existing && node.type === "connection") { return inheritNaturalTreeNodeOrder(node, { ...existing, label: node.label, pinned: node.pinned, children: withSavedSqlRoot(node.connectionId!, existing.children || [], existing), }); } if (node.type === "connection" && node.connectionId) { return inheritNaturalTreeNodeOrder(node, { ...node, children: withSavedSqlRoot(node.connectionId, node.children || []) }); } return node; }); const mergedNodes = mergeState(freshNodes); const migratedPins = migrateLegacyPinnedTreeNodeOrder(mergedNodes, pinnedTreeNodeOrder.value); if (migratedPins.changed) { setPinnedTreeNodeOrder(migratedPins.order); persistPinnedTreeNodeIds(); } syncPinnedTreeState(mergedNodes); treeNodes.value = mergedNodes; } function updateLayoutAndRebuild(nextLayout: SidebarLayout) { sidebarLayout.value = nextLayout; rebuildTreeNodes(); persistSidebarLayoutDebounced(); } function collapseAllTreeNodes() { updateLayoutAndRebuild(collapseAllGroupsOp(sidebarLayout.value)); collapseExpandedTreeNodes(treeNodes.value); } async function refreshAllTree() { const expandedIds = collectExpandedNodeIds(treeNodes.value); const refreshExpandedNodes = async (nodes: TreeNode[]) => { for (const node of nodes) { if (node.type === "connection-group") { if (node.children) await refreshExpandedNodes(node.children); continue; } if (!expandedIds.has(node.id)) continue; if (node.connectionId && !connectedIds.value.has(node.connectionId)) continue; clearLoadedChildrenCache(node.id); node.children = []; await loadTreeNodeChildren(node, { force: true }); await restoreExpandedChildren(node, expandedIds, { force: true }); } }; await refreshExpandedNodes(treeNodes.value); } async function refreshSidebarObjectPagination() { const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; const isDirectObjectParent = (node: TreeNode) => { if (!node.children || node.children.length === 0) return false; return node.children.some( (child) => child.type === "table" || child.type === "view" || child.type === "materialized_view" || child.type === "procedure" || child.type === "function" || child.type === "sequence" || child.type === "package" || child.type === "package-body" || child.type === "load-more", ); }; const refreshNodes = async (nodes: TreeNode[]) => { for (const node of nodes) { if (node.type === "connection-group") { if (node.children) await refreshNodes(node.children); continue; } if (objectTypesForGroupNode(node.type)) { if (node.connectionId && connectedIds.value.has(node.connectionId)) { clearLoadedChildrenCache(node.id); if (node.isExpanded) { await loadObjectGroupChildren(node, { force: true }); } else if (node.children) { node.children = []; } } continue; } if (simpleObjectDisplay && (node.type === "database" || node.type === "schema" || node.type === "linked-server-schema")) { if (isDirectObjectParent(node)) { if (node.connectionId && connectedIds.value.has(node.connectionId)) { clearLoadedChildrenCache(node.id); if (node.isExpanded) { await refreshTreeNode(node); } else { node.children = []; } } continue; } if (node.children) await refreshNodes(node.children); continue; } if (node.children) await refreshNodes(node.children); } }; await refreshNodes(treeNodes.value); } async function exportConnectionsToFile(passphrase: string) { const { encryptConfig } = await import("@/lib/backend/configCrypto"); const tunnelProfileStore = useTunnelProfileStore(); await tunnelProfileStore.init(); // Older DBX versions ignore inheritance flags, so always include the // effective numeric values as a backward-compatible snapshot. const exportedConnections = connections.value.map((connection) => ({ ...connection, connect_timeout_secs: connection.connect_timeout_inherit === true ? settingsStore.editorSettings.globalConnectTimeoutSecs : connection.connect_timeout_secs, query_timeout_secs: connection.query_timeout_inherit === true ? settingsStore.editorSettings.globalQueryTimeoutSecs : connection.query_timeout_secs, })); const exportData = { connections: exportedConnections, layout: sidebarLayout.value, tunnelProfiles: tunnelProfileStore.profiles }; const json = JSON.stringify(exportData); const payload = await encryptConfig(json, passphrase); const content = JSON.stringify(payload, null, 2); if (isTauriRuntime()) { const { save } = await import("@tauri-apps/plugin-dialog"); const { writeTextFile } = await import("@tauri-apps/plugin-fs"); const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json", }); if (!path) return; await writeTextFile(path, content); } else { const blob = new Blob([content], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "dbx-connections.json"; a.click(); URL.revokeObjectURL(url); } } function bytesToBase64(bytes: Uint8Array) { let binary = ""; const chunkSize = 0x8000; for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode(...bytes.slice(i, i + chunkSize)); } return btoa(binary); } function siblingCredentialsPath(path: string) { const fileName = path.split(/[\\/]/).pop() || ""; const credentialsFile = fileName.startsWith("data-sources-") ? fileName.replace(/^data-sources/, "credentials-config") : "credentials-config.json"; return path.replace(/[^\\/]+$/, credentialsFile); } async function readDbeaverImportFile(): Promise<{ content: string; encrypted: boolean } | null> { let dataSources: string; let credentialsBase64 = ""; if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); const { readTextFile, readFile } = await import("@tauri-apps/plugin-fs"); const path = await open({ filters: [{ name: "DBeaver Data Sources", extensions: ["json"] }], multiple: false, }); if (!path) return null; const dataSourcesPath = path as string; dataSources = await readTextFile(dataSourcesPath); try { credentialsBase64 = bytesToBase64(await readFile(siblingCredentialsPath(dataSourcesPath))); } catch { credentialsBase64 = ""; } } else { const files = await new Promise((resolve, reject) => { const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; input.multiple = true; input.onchange = () => { if (!input.files?.length) { reject(new Error("No file selected")); return; } resolve(input.files); }; input.click(); }); const fileList = Array.from(files); const dataSourcesFile = fileList.find((file) => /^data-sources.*\.json$/i.test(file.name)) || fileList.find((file) => !/^credentials-config.*\.json$/i.test(file.name)); const credentialsFile = fileList.find((file) => /^credentials-config.*\.json$/i.test(file.name)); if (!dataSourcesFile) throw new Error("Select DBeaver data-sources.json"); dataSources = await dataSourcesFile.text(); if (credentialsFile) { credentialsBase64 = bytesToBase64(new Uint8Array(await credentialsFile.arrayBuffer())); } } return { content: JSON.stringify({ format: "dbeaver-import", dataSources, credentialsBase64 }), encrypted: false, }; } async function readDataGripImportFile(): Promise<{ content: string; encrypted: boolean } | null> { let dataSources: string; let dataSourcesLocal = ""; let dbForestConfig = ""; if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); const { readTextFile } = await import("@tauri-apps/plugin-fs"); const { matchDataGripImportFiles } = await import("@/lib/imports/datagripImport"); const paths = await open({ multiple: true, filters: [{ name: "DataGrip configuration files", extensions: ["xml"] }], title: i18n.global.t("configExport.importDatagripDialogTitle"), }); if (!paths || paths.length === 0) return null; // Tauri's fs scope authorizes only the exact paths picked in the dialog, // so every file read below must be explicitly selected — sibling files in // the same directory (e.g. dataSources.local.xml) are NOT readable. let picked: { dataSources: string; local?: string; forest?: string }; try { picked = matchDataGripImportFiles(Array.isArray(paths) ? paths : [paths]); } catch (error) { if ((error as Error & { code?: string })?.code === "DATAGRIP_IMPORT_MISSING_DATASOURCES") { throw new Error(i18n.global.t("configExport.importDatagripSelectFiles")); } throw error; } dataSources = await readTextFile(picked.dataSources); if (picked.local) { dataSourcesLocal = await readTextFile(picked.local); } else { console.warn("[DataGrip Import] dataSources.local.xml not selected; usernames will fall back to defaults"); } if (picked.forest) { dbForestConfig = await readTextFile(picked.forest); } else { console.warn("[DataGrip Import] db-forest-config.xml not selected; legacy group tree skipped"); } } else { const files = await new Promise((resolve, reject) => { const input = document.createElement("input"); input.type = "file"; input.accept = ".xml"; input.multiple = true; input.onchange = () => { if (!input.files?.length) { reject(new Error("No file selected")); return; } resolve(input.files); }; input.click(); }); const fileList = Array.from(files); const dsFile = fileList.find((f) => /^dataSources\.xml$/i.test(f.name)); const localFile = fileList.find((f) => /^dataSources\.local\.xml$/i.test(f.name)); const forestFile = fileList.find((f) => /^db-forest-config\.xml$/i.test(f.name)); if (!dsFile) throw new Error("Select dataSources.xml"); dataSources = await dsFile.text(); if (localFile) { dataSourcesLocal = await localFile.text(); } if (forestFile) { dbForestConfig = await forestFile.text(); } } return { content: JSON.stringify({ format: "datagrip-import", dataSources, dataSourcesLocal, dbForestConfig }), encrypted: false, }; } async function readImportFile(source: ImportSource = "dbx"): Promise<{ content: string; encrypted: boolean } | null> { if (source === "dbeaver") return readDbeaverImportFile(); if (source === "datagrip") return readDataGripImportFile(); let content: string; if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); const { readTextFile } = await import("@tauri-apps/plugin-fs"); const path = await open({ filters: source === "navicat" ? [{ name: "Navicat Connection Export", extensions: ["ncx", "xml"] }] : [{ name: "DBX JSON", extensions: ["json"] }], multiple: false, }); if (!path) return null; content = await readTextFile(path as string); } else { content = await new Promise((resolve, reject) => { const input = document.createElement("input"); input.type = "file"; input.accept = source === "navicat" ? ".ncx,.xml" : ".json"; input.onchange = () => { const file = input.files?.[0]; if (!file) { reject(new Error("No file selected")); return; } const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => reject(reader.error); reader.readAsText(file); }; input.click(); }); } if (content.trimStart().startsWith("<")) { return { content, encrypted: false }; } const { isEncryptedConfig } = await import("@/lib/backend/configCrypto"); const parsed = JSON.parse(content); return { content, encrypted: isEncryptedConfig(parsed) }; } async function importConnectionsFromFile(content: string, passphrase: string | null): Promise<{ count: number; layout?: SidebarLayout }> { let imported: ConnectionConfig[] = []; let importedLayout: SidebarLayout | undefined; let importedTunnelProfiles: TunnelProfile[] = []; if (!passphrase && content.trimStart().startsWith("<")) { const { parseNavicatConnections } = await import("@/lib/imports/navicatImport"); imported = await parseNavicatConnections(content); } else if (!passphrase) { const { isDbeaverImportPayload, parseDbeaverImport } = await import("@/lib/imports/dbeaverImport"); const { isDataGripImportPayload, parseDataGripImport } = await import("@/lib/imports/datagripImport"); if (isDataGripImportPayload(content)) { const payload = JSON.parse(content) as { format: "datagrip-import"; dataSources: string; dataSourcesLocal?: string; dbForestConfig?: string; }; pendingDataGripPayload = payload; const result = parseDataGripImport(payload); imported = result.connections; importedLayout = result.layout; } else if (isDbeaverImportPayload(content)) { const result = await parseDbeaverImport(content); imported = result.connections; importedLayout = result.layout; } else { const parsed = JSON.parse(content); if (Array.isArray(parsed)) { imported = parsed; } else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) { imported = parsed.connections; } else if (parsed.connections && Array.isArray(parsed.connections)) { imported = parsed.connections; if (parsed.layout?.groups && parsed.layout?.order) { importedLayout = parsed.layout; } if (Array.isArray(parsed.tunnelProfiles)) { importedTunnelProfiles = parsed.tunnelProfiles; } } else { imported = []; } } } else { const parsed = JSON.parse(content); if (passphrase) { const { decryptConfig } = await import("@/lib/backend/configCrypto"); const json = await decryptConfig(parsed, passphrase); const decrypted = JSON.parse(json); if (Array.isArray(decrypted)) { imported = decrypted; } else if (decrypted.connections) { imported = decrypted.connections; if (decrypted.layout?.groups && decrypted.layout?.order) { importedLayout = decrypted.layout; } if (Array.isArray(decrypted.tunnelProfiles)) { importedTunnelProfiles = decrypted.tunnelProfiles; } } else { imported = []; } } } // Profiles keep their original ids: imported connections reference them // via transport_layers[].profile_id, so regenerating ids would break the // links. Same-id profiles are overwritten with the imported copy. if (importedTunnelProfiles.length) { const tunnelProfileStore = useTunnelProfileStore(); await tunnelProfileStore.init(); const merged = [...tunnelProfileStore.profiles]; for (const profile of importedTunnelProfiles) { if (!profile || typeof profile.id !== "string" || !profile.id) continue; const index = merged.findIndex((existing) => existing.id === profile.id); if (index >= 0) merged[index] = profile; else merged.push(profile); } await tunnelProfileStore.saveProfiles(merged); } let count = 0; const importedConnectionIdMap = new Map(); for (const config of imported) { const duplicate = connections.value.find((c) => c.name === config.name && c.host === config.host && c.port === config.port); if (!duplicate) { const importedId = config.id; config.id = uuid(); if (typeof importedId === "string") importedConnectionIdMap.set(importedId, config.id); const normalized = normalizeConnection(config); await addConnection(normalized); count++; } else if (typeof config.id === "string") { importedConnectionIdMap.set(config.id, duplicate.id); } } if (importedLayout) { importedLayout = remapSidebarLayoutConnectionIds(importedLayout, importedConnectionIdMap); } return { count, layout: importedLayout }; } /** Read macOS Keychain passwords for DataGrip connections and update them. */ async function applyDataGripKeychainPasswords(): Promise { const payload = pendingDataGripPayload; pendingDataGripPayload = null; if (!payload) return 0; try { const { getDataGripUuidMap, datagripKeychainService } = await import("@/lib/imports/datagripImport"); // dedupKey → DataGrip UUID const uuidMap = getDataGripUuidMap(payload); if (uuidMap.size === 0) return 0; // Build service names for batch Keychain read const dedupKeyToService = new Map(); const services: string[] = []; for (const [dedupKey, dgUuid] of uuidMap) { const service = datagripKeychainService(dgUuid); dedupKeyToService.set(dedupKey, service); services.push(service); } // Call Tauri command to read Keychain const results: [string, string][] = await api.readKeychainPasswords(services); // Build service → password map const passwordByService = new Map(); for (const [service, password] of results) { if (password) passwordByService.set(service, password); } // Update connections that have passwords (match by name/host/port) let filled = 0; const updated = connections.value.map((conn) => { const dedupKey = [conn.name, conn.host, conn.port, conn.database || ""].join("\u0000"); const service = dedupKeyToService.get(dedupKey); if (!service) return conn; const password = passwordByService.get(service); if (password) { filled++; return { ...conn, password }; } return conn; }); if (filled > 0) { connections.value = updated; await persistConnections(); } return filled; } catch (e) { console.warn("[DataGrip Import] Keychain read failed:", e); return 0; } } function applySidebarLayout(layout: SidebarLayout) { const reconciledLayout = reconcileLayout( connections.value.map((c) => c.id), layout, ); updateLayoutAndRebuild(reconciledLayout); } async function initFromDisk() { // Connection normalization and timeout migration depend on persisted global // settings. Startup helpers may initialize connections before App.initApp(). await settingsStore.initEditorSettings(); if (!initFromDiskPromise) { initFromDiskPromise = (async () => { const [pinnedOrder, saved] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init()]); setPinnedTreeNodeOrder(pinnedOrder); await migrateTimeoutInheritance(saved); connections.value = saved.map(normalizeConnection); if (connections.value.some((connection, index) => (connection.connect_timeout_inherit === true && connection.connect_timeout_secs !== saved[index]?.connect_timeout_secs) || (connection.query_timeout_inherit === true && connection.query_timeout_secs !== saved[index]?.query_timeout_secs))) { await persistConnections(); } syncTimeoutInheritanceBackup(); const savedLayout = await api.loadSidebarLayout(); const currentLayout = sidebarLayout.value.groups.length || sidebarLayout.value.order.length ? sidebarLayout.value : null; sidebarLayout.value = reconcileLayout( connections.value.map((c) => c.id), savedLayout ?? currentLayout, ); rebuildTreeNodes(); })().finally(() => { initFromDiskPromise = null; }); } await initFromDiskPromise; } function addEphemeralConnection(config: ConnectionConfig) { const normalized = normalizeConnection(config); if (!connections.value.find((c) => c.id === normalized.id)) { connections.value.push(normalized); } connectedIds.value.add(normalized.id); markConnectionHealthChecked(normalized.id); clearConnectionError(normalized.id); } function cancelTreeNodeLoad(nodeId: string): void { // Supersede any in-flight loader for this node so a collapse issued while // the load is still running (or a loader that never resolves) cannot // reclaim ownership after connection recovery or re-expand the node. treeNodeLoads.cancelPrefix(nodeId); const node = findNode(treeNodes.value, nodeId); if (node) node.isLoading = false; } return { connections, activeConnectionId, selectedTreeNodeId, selectedTreeNodeIds, selectedTreeNodeIdsSet, treeSelectionAnchorId, connectionMultiSelectActive, treeClipboard, treeNodes, removePinnedTreeNodes, replacePinnedTreeNode, removeTreeNode, refreshAllTree, collapseAllTreeNodes, refreshSidebarObjectPagination, refreshTreeNode, refreshDatabaseTreeNode, refreshObjectListTreeNode, connectedIds, connectingIds, connectionErrors, setConnectionError, clearConnectionError, recordConnectionError, markConnectionLost, recordConnectionLostError, sidebarLayout, connectionGroupPaths, getConfig, connectionIdentifierQuote, isTreeNodePinned, orderByPinnedTreeNodes, toggleTreeNodePin, beginPinnedTreeNodeReorder, endPinnedTreeNodeReorder, isPinnedTreeNodeReorderTarget, canReorderPinnedTreeNodes, reorderPinnedTreeNodes, addConnection, copyConnectionsToTreeClipboard, pasteConnectionClipboard, addEphemeralConnection, updateConnection, applyGlobalTimeouts, updateConnectionDatabaseInfo, setDefaultDatabase, clearDefaultDatabase, isDefaultDatabase, setDefaultSchema, clearDefaultSchema, isDefaultSchema, getRedisDatabaseAlias, setRedisDatabaseAlias, setVisibleDatabases, clearVisibleDatabases, ensureVisibleDatabase, setVisibleSchemas, clearVisibleSchemas, recordPrimaryVisibleObjectNames, getSidebarVisibleFilterSummary, removeConnection, removeConnections, editingConnectionId, newConnectionGroupId, startEditing, stopEditing, startCreatingConnectionInGroup, stopCreatingConnectionInGroup, connect, cancelConnecting, disconnect, closeDatabaseConnection, ensureConnected, loadConnectedConnectionRootForSidebarSearch, isTreeNodeChildrenLoaded, canUseLoadedTreeNodeToggle, releaseCollapsedTreeNodeChildren, cancelTreeNodeLoad, setBeforeConnectHandler, initFromDisk, loadDatabases, loadSidebarDatabaseStorage, loadSidebarTableStorage, loadRedisDatabases, refreshRedisDbKeyCounts, loadEtcdRoot, loadZooKeeperRoot, loadConsulRoot, loadMqTenants, loadMqttTopics, loadNacosNamespaces, updateRedisDbKeyStats, loadMongoDatabases, loadMilvusDatabases, openElasticsearchConnectionTree, loadElasticsearchIndices, loadVectorCollections, loadMongoCollections, loadSchemas, loadSqlServerDatabaseObjects, loadSqlServerLinkedServers, loadSqlServerLinkedServerCatalogs, loadSqlServerLinkedServerSchemas, loadDorisCatalogDatabases, loadDorisCatalogTables, loadTables, loadTableForLocate, loadObjectGroupChildren, loadPackageMembers, loadMoreObjectGroupChildren, loadAllObjectGroupChildren, loadTableGroups, loadTreeNodeChildren, loadColumns, loadIndexes, loadForeignKeys, loadTriggers, loadConstraints, loadPartitions, loadSubpartitions, listCompletionTables, listCompletionObjects, listCompletionColumns, listCompletionForeignKeys, listCompletionSchemas, listCompletionDatabases, getSqlServerCompletionContext, lookupLocalCompletionTables, lookupLocalCompletionObjects, lookupLocalCompletionColumns, lookupLocalCompletionForeignKeys, lookupLocalCompletionSchemas, lookupLocalCompletionDatabases, refreshCompletionTables, refreshCompletionObjects, refreshCompletionColumns, refreshCompletionForeignKeys, refreshCompletionSchemas, refreshCompletionDatabases, listElasticsearchCompletionIndices, listRedisCompletionKeys, listMongoCompletionCollections, listMongoCompletionFields, invalidateCompletionCache, invalidateCompletionTableCache, invalidateMetadataCache, exportConnectionsToFile, readImportFile, importConnectionsFromFile, applyDataGripKeychainPasswords, applySidebarLayout, transferSource, schemaDiffSource, dataCompareSource, sqlFileSource, diagramSource, docsSource, tableImportSource, tableDataGenerateSource, fieldLineageSource, databaseSearchSource, databaseExportSource, sidebarSearchQuery, sidebarTableSearchQueries, sidebarTableNameFilters, tableNameFilterScopeKey, tableNameFilterForScope, setSidebarTableNameFilter, refreshTreeNodeForTableNameFilter, setSidebarTableSearchQuery, refreshSidebarTableSearch, loadSidebarTableSearchIndex, refreshSidebarTableSearchIndex, createConnectionGroup(name: string, parentGroupId?: string | null) { const result = createGroupOp(sidebarLayout.value, name, parentGroupId); updateLayoutAndRebuild(result.layout); return result.groupId; }, renameConnectionGroup(groupId: string, name: string) { updateLayoutAndRebuild(renameGroupOp(sidebarLayout.value, groupId, name)); }, deleteConnectionGroup(groupId: string) { updateLayoutAndRebuild(deleteGroupOp(sidebarLayout.value, groupId)); }, toggleConnectionGroupCollapsed(groupId: string) { updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId)); }, moveConnectionToGroup(connectionId: string, groupId: string | null) { updateLayoutAndRebuild(moveConnectionToGroupOp(sidebarLayout.value, connectionId, groupId)); }, groupIdForConnection(connectionId: string): string | null { return findConnectionLocation(sidebarLayout.value, connectionId)?.groupId ?? null; }, reorderSidebarEntry(draggedId: string, targetId: string, position: DropPosition) { updateLayoutAndRebuild(reorderEntryOp(sidebarLayout.value, draggedId, targetId, position)); }, reorderSidebarEntries(draggedIds: string[], targetId: string, position: DropPosition) { // Apply each dragged entry in turn so a multi-selection moves together, // not just the single grabbed row (issue #681). let layout = sidebarLayout.value; let changed = false; for (const id of draggedIds) { if (id === targetId) continue; layout = reorderEntryOp(layout, id, targetId, position); changed = true; } if (changed) updateLayoutAndRebuild(layout); }, }; });