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

10060 lines
474 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineStore } from "pinia";
import type { SqlFilePreview } from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
import { ref, computed, watch, markRaw } from "vue";
import { DEFAULT_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits";
import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents";
import type {
ColumnInfo,
CompletionAssistantCandidate,
CompletionAssistantMatchMode,
CompletionAssistantObjectKind,
CompletionAssistantRequest,
ConnectionConfig,
DatabaseType,
DatabaseConnectionInfo,
DatabaseStorageInfo,
SqlServerCompletionContext,
CatalogInfo,
ForeignKeyInfo,
ObjectInfo,
ObjectStatistics,
SchemaInfo,
SidebarLayout,
TableNameFilter,
TableInfo,
TreeNode,
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,
deleteGroups as deleteGroupsOp,
connectionIdsInGroups as connectionIdsInGroupsOp,
toggleGroupCollapsed as toggleGroupCollapsedOp,
expandGroups as expandGroupsOp,
collapseAllGroups as collapseAllGroupsOp,
moveConnectionToGroup as moveConnectionToGroupOp,
mergeSidebarLayout,
reorderEntry as reorderEntryOp,
reorderEntries as reorderEntriesOp,
buildConnectionGroupPathMap,
connectionGroupDestinationRows,
connectionGroupIdForSelection,
connectionSidebarSearchAliases,
type DropPosition,
type ReorderEntriesOptions,
} from "@/lib/sidebar/sidebarLayout";
import {
applyTableVGroupsToChildren,
collectTableTreeNames,
createTableVGroup as createTableVGroupOp,
deleteTableVGroups as deleteTableVGroupsOp,
emptyTableVGroupLayout,
findTableVGroupContainerNode,
hasTableTreeLoadMore,
hasTableVGroupEntries,
moveTableToVGroup as moveTableToVGroupOp,
normalizeTableVGroupLayout as normalizeTableVGroupLayoutOp,
pruneTableVGroupMembers as pruneTableVGroupMembersOp,
reorderTableVGroupEntry as reorderTableVGroupEntryOp,
renameTableVGroup as renameTableVGroupOp,
resolveTableVGroupScopeFromNode,
setTableVGroupsEnabled as setTableVGroupsEnabledOp,
stripTableVGroupsFromChildren,
tableVGroupKindOfContainerNode,
tableVGroupPathForTable as tableVGroupPathForTableOp,
tableVGroupScopeKey,
toggleTableVGroupCollapsed as toggleTableVGroupCollapsedOp,
type TableVGroupDropPosition,
type TableVGroupLayout,
type TableVGroupScope,
} from "@/lib/table/tableVGroup";
import {
buildConnectionConfigBundle,
parseConnectionConfigObject,
prepareConnectionConfigImport,
selectConnectionConfigBundle,
scrubConnectionForPlaintextExport,
scrubTunnelProfileForPlaintextExport,
snapshotConnectionsForExport,
type ConnectionConfigBundle,
type ConnectionExportProtection,
} from "@/lib/connection/connectionConfigTransfer";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { usesOracleCurrentSchemaCompletion, isOracleCompletionDatabase } from "@/lib/sql/oracleCompletionSession";
import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import * as api from "@/lib/backend/api";
import { oracleDatabaseLinksFromResult, oracleDatabaseLinksSql, supportsOracleDatabaseLinks } from "@/lib/database/oracleDatabaseLinks";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, schemaNodeHasLoadableName, shouldShowDorisCatalogTree, sidebarObjectKindsForDatabase, supportsPackageMemberExpansion, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import {
connectionDatabaseMetadataSchema,
connectionObjectTreeNodeSchema,
connectionObjectTreeQuerySchema,
connectionShouldDiscoverJdbcSchemas,
connectionShouldLoadIdentifierQuote,
connectionUsesDatabaseObjectTreeMode,
effectiveDatabaseTypeForConnection,
gaussdbIdentifierQuoteOverride,
} from "@/lib/database/jdbcDialect";
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
import { spannerDisplayDatabase, spannerSchemaDisplayName } from "@/lib/connection/spannerResourcePath";
import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree";
import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse";
import { findNodePathByIdentity, nodeMatchesRegexScopeIdentity, type SidebarRegexScopeIdentity } from "@/lib/sidebar/sidebarSearchTree";
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 { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
import { deleteTabResultSnapshotsForOwner } from "@/lib/tabs/tabResultCache";
import { deletedConnectionTabKeepMode } from "@/lib/tabs/deletedConnectionTabs";
import { disposeSqlServerActivityTracesForConnection, hasSqlServerActivityTraceForConnection } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForConnection, filterSchemaNamesForConnection, filterVisibleDatabaseNames, isDraftVisibleSchemasConnectionId, normalizeVisibleDatabaseSelection, visibleDatabasePatternsAreEnabled } from "@/lib/database/visibleDatabases";
import {
buildObjectGroupPlaceholderNodes,
buildGroupedObjectTreeNodes,
buildSimpleObjectTreeNodes,
buildTableTreeNodes,
appendTableTreeLoadMoreNode,
expandCachedObjectBrowserNodes,
filterSimpleSidebarSupplementalObjects,
mergeTableInfosIntoObjects,
mergeTableTreePageChildren,
objectGroupRefreshParentId,
objectTypesForGroupNode,
tablePartitionGroups,
withoutTableTreeLoadMoreNodes,
type TableTreeLoadMoreParent,
type DatabaseObjectTreeKind,
} from "@/lib/table/tableTree";
import { hasTreeNodeDatabaseContext, normalizeCataloglessDatabaseNodes, treeNodeSchemaCachePrefix } from "@/lib/sidebar/treeNodeContext";
import { decodeSchemaTreeCache, decodeTableSearchIndexManifest, encodeSchemaTreeCache, encodeTableSearchIndexManifest, type TableSearchIndexManifestEntry } from "@/lib/metadata/schemaTreeCache";
import { sortSidebarTreeChildrenByNameKeepingTableVGroups, sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { mergeRedisCommandDocumentation, parseRedisCommandCatalog, parseRedisCommandDocumentation, type RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, stripDatabaseSavedSqlTreeNodes, withDatabaseSavedSqlRoot } from "@/lib/savedSql/savedSqlDatabaseTree";
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
import { flattenElasticsearchMappingFields, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion";
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
import { mongoCollectionKindFromNode, toMongoCollectionKind, visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
import { etcdPermissionsAllowKey } from "@/lib/etcd/keyPermissions";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { limitRedisDatabaseList, normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { appendAgentDriverUpdateHint, connectionUsesSsh, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints, isMysqlMissingPasswordFailure, isSqliteMissingEncryptionPasswordFailure } from "@/lib/connection/connectionErrorHints";
import { connectionNeedsPasswordPrompt, pluginConnectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { buildXuguTypeMemberNodes, isXuguTypeMemberContainer } from "@/lib/sidebar/xuguTypeMembers";
import { isXuguPublicSynonymScope, isXuguSchedulerJobScope, isXuguSyntheticScope, sortXuguSchemaInfos, xuguSchemaDisplayName, XUGU_PUBLIC_SYNONYM_SCOPE, XUGU_SCHEDULER_JOB_SCOPE } from "@/lib/sidebar/xuguPublicSynonyms";
import { filterNacosNamespacesForSidebar, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
import { buildPackageMemberNodes, markPackageNodesExpandable, packageMemberGroupOwnerId } from "@/lib/sidebar/packageMembers";
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
import { driverProfileObjectTreeProfileForConnection } from "@/lib/database/driverProfileExtensions";
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 { cancelObjectDdlLoadsForConnection, cancelObjectDdlLoadsForDatabase, invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache";
import { cancelObjectMetadataLoadsForConnection, cancelObjectMetadataLoadsForDatabase } from "@/lib/metadata/objectMetadataCache";
import { clearMetadataRuntimeCacheForConnection, clearMetadataRuntimeCacheForDatabase } from "@/lib/metadata/metadataRuntimeCache";
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import { buildCustomTypeTreeChildren } from "@/lib/sidebar/customTypeTree";
import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle";
import { buildXuguTablespaceChildren } from "@/lib/sidebar/xuguTablespaces";
import i18n, { currentLocale } 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;
/** How long a successful driver/pool warm-up is trusted before the next tab
* activation warms the same pool target again. */
const CONNECTION_PREWARM_TTL_MS = 30_000;
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;
const SIDEBAR_DATABASE_STORAGE_CACHE_MAX_ENTRIES = 32;
const SIDEBAR_TABLE_STORAGE_CACHE_MAX_ENTRIES = 64;
const SIDEBAR_TABLE_SEARCH_INDEX_CACHE_MAX_ENTRIES = 32;
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";
const SHARDINGSPHERE_PROXY_VERSION_MARKER = "shardingsphere-proxy";
export interface EtcdAccessCapabilities {
/** May access cluster-wide administration APIs such as status and leases. */
admin: boolean;
/** Has at least one etcd role that permits writes to a Key range. */
writable: boolean;
/** Null means unrestricted; otherwise writes are limited to these etcd ranges. */
writePermissions: api.EtcdAuthPermission[] | null;
}
const unrestrictedEtcdAccess: EtcdAccessCapabilities = { admin: true, writable: true, writePermissions: null };
const restrictedEtcdAccess: EtcdAccessCapabilities = { admin: false, writable: false, writePermissions: [] };
const fallbackEtcdV2Access: EtcdAccessCapabilities = { admin: false, writable: true, writePermissions: null };
function usesShardingSphereLogicalTables(databaseInfo: DatabaseConnectionInfo | undefined): boolean {
return databaseInfo?.productVersion?.toLowerCase().includes(SHARDINGSPHERE_PROXY_VERSION_MARKER) === true;
}
function mysqlTableListSourceChanged(config: ConnectionConfig, databaseInfo: DatabaseConnectionInfo): boolean {
return config.db_type === "mysql" && usesShardingSphereLogicalTables(config.database_info) !== usesShardingSphereLogicalTables(databaseInfo);
}
function normalizeTableNameFilter(filter: Partial<TableNameFilter> | 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<string, TableNameFilter> {
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<string, TableNameFilter> = {};
for (const [key, value] of Object.entries(parsed)) {
const filter = normalizeTableNameFilter(value as Partial<TableNameFilter>);
if (!tableNameFilterIsEmpty(filter)) result[key] = filter;
}
return result;
} catch {
return {};
}
}
function saveSidebarTableNameFilters(filters: Record<string, TableNameFilter>) {
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;
}
/**
* Upper bound for a single remote fuzzy table-search result set.
*
* Every fuzzy match travels database → IPC → store → tree rendering, so an
* unbounded result set (e.g. a single-letter query against a large schema)
* would push thousands of rows through the whole pipeline. The budget is 4×
* the default page size (500) and comfortably covers the reported #6190
* schema (801 fuzzy matches) while keeping a single IPC payload bounded.
* Queries whose fuzzy match set exceeds the budget are truncated; narrowing
* the query reaches later tables.
*/
export const SIDEBAR_TABLE_SEARCH_RESULT_BUDGET = 2000;
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<MqAdminConfig> | 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[];
}
| {
kind: "saved-sql-copy";
fileIds: string[];
};
interface LoadTreeOptions {
force?: boolean;
connectedOnly?: boolean;
expectedSidebarSearchQuery?: string;
searchFilter?: string;
// Set by the sidebar search walker: the load runs in the background, so a connection that
// cannot be reached must not leave the raw driver error on the node (see
// withSidebarSearchLoad).
sidebarSearch?: boolean;
// Explicit actions can load the unfiltered backing group while the global search
// continues to control presentation; normal watcher refreshes still reject mismatches.
allowGlobalSearchMismatch?: boolean;
onChildrenApplied?: (node: TreeNode) => void;
sidebarTableSearchParentId?: string;
expectedSidebarTableSearchQuery?: string;
tableNameFilterScopeKey?: string;
expectedTableNameFilterRevision?: number;
preserveCollapsedChildren?: boolean;
}
interface PersistedTreeChildrenLoadResult {
hit: boolean;
isStale: boolean;
}
type MetadataListPageResult = TableInfo[] | ObjectInfo[];
type BeforeConnectHandler = (config: ConnectionConfig) => Promise<void>;
export const CONNECTION_ATTEMPT_CANCELLED_MESSAGE = "Connection attempt was cancelled";
/** Thrown when a no-save-password connection is connected without a typed password. */
export const CONNECTION_PASSWORD_REQUIRED_MESSAGE = "Password is required for this connection";
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 savedSqlStore = useSavedSqlStore();
let savedSqlFilesByDatabase = indexSavedSqlFilesByDatabase(savedSqlStore.allFiles);
const connections = ref<ConnectionConfig[]>([]);
const isDesktop = isTauriRuntime();
// Prefer a safe read so Node/vitest (missing or partial storage mocks) do not
// throw when query tabs resolve connection db_type defaults via this store.
const activeConnectionId = ref<string | null>(
(() => {
try {
const storage = globalThis.localStorage;
if (!storage || typeof storage.getItem !== "function") return null;
return storage.getItem(ACTIVE_CONNECTION_STORAGE_KEY);
} catch {
return null;
}
})(),
);
const selectedTreeNodeId = ref<string | null>(null);
const selectedTreeNodeIds = ref<string[]>([]);
// 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<string | null>(null);
// Legacy name: this flag now covers homogeneous checkbox selections for both
// connections and connection groups. Connection-only toolbars still filter
// selected ids against the saved connection list.
const connectionMultiSelectActive = ref(false);
const treeClipboard = ref<TreeClipboard | null>(null);
watch(activeConnectionId, (id) => {
try {
const storage = globalThis.localStorage;
if (!storage || typeof storage.setItem !== "function" || typeof storage.removeItem !== "function") return;
if (id) storage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, id);
else storage.removeItem(ACTIVE_CONNECTION_STORAGE_KEY);
} catch {
// Ignore storage failures in non-browser / partial mock environments.
}
});
const treeNodes = ref<TreeNode[]>([]);
const sidebarDatabaseStorageCache = new MetadataResultCache<DatabaseStorageInfo[]>({
ttlMs: SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
maxEntries: SIDEBAR_DATABASE_STORAGE_CACHE_MAX_ENTRIES,
});
const sidebarDatabaseStorageInFlight = new Map<string, Promise<DatabaseStorageInfo[]>>();
const sidebarTableStorageCache = new MetadataResultCache<ObjectStatistics[]>({
ttlMs: SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
maxEntries: SIDEBAR_TABLE_STORAGE_CACHE_MAX_ENTRIES,
});
const sidebarTableStorageInFlight = new Map<string, Promise<ObjectStatistics[]>>();
const pinnedTreeNodeOrder = ref<string[]>([]);
const pinnedTreeNodeIds = ref<Set<string>>(new Set());
const activePinnedTreeNodeReorderKey = ref<string | null>(null);
let pinnedTreeNodePersistQueue: Promise<void> = Promise.resolve();
const connectedIds = ref<Set<string>>(new Set());
const etcdAccessCapabilities = ref<Record<string, EtcdAccessCapabilities>>({});
const etcdAccessCapabilityGenerations = new Map<string, number>();
const etcdAccessCapabilityLoads = new Map<string, Promise<EtcdAccessCapabilities>>();
const identifierQuotes = ref<Record<string, string>>({});
/** Per-database compatibility mode (key `${connectionId}\u0000${database}`),
* decoupled from sidebar tree-node lifecycle so editor parsing and sidebar
* capabilities stay correct before/independent of database-tree rendering. */
const databaseCompatibilityModes = ref<Record<string, string>>({});
const databaseCompatibilityRefreshes = new Map<string, Promise<void>>();
const lastConnectionHealthCheckAt = ref<Record<string, number>>({});
/** Per pool-target timestamp of the last successful driver/pool warm-up (see
* `warmConnection`). Fire-and-forget: a skipped warm-up only means the pool is
* created by the first real request instead. */
const lastConnectionPrewarmAt = ref<Record<string, number>>({});
const connectionPrewarmInFlight = new Map<string, Promise<void>>();
const agentDrivers = ref<AgentDriverInstallState[]>([]);
let agentDriversRefreshPromise: Promise<void> | null = null;
let localAgentDriversRefreshPromise: Promise<void> | null = null;
const loadedTreeNodeChildrenIds = ref<Set<string>>(new Set());
/** Simple-mode database/schema nodes loaded successfully with zero objects (not refresh stale shells). */
const confirmedEmptyTreeNodeIds = ref<Set<string>>(new Set());
const connectionErrors = ref<Record<string, string>>({});
const connectingIds = ref<Set<string>>(new Set());
const editingConnectionId = ref<string | null>(null);
const newConnectionGroupId = ref<string | null>(null);
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
const completionObjectsCache = ref<Record<string, SqlCompletionObject[]>>({});
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
const completionForeignKeysCache = ref<Record<string, ForeignKeyInfo[]>>({});
const completionDatabasesCache = ref<Record<string, string[]>>({});
const primaryVisibleObjectNames = ref<Record<string, string[]>>({});
const sqlServerCompletionContextCache = ref<Record<string, SqlServerCompletionContext>>({});
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
const elasticsearchCompletionFieldsCache = ref<Record<string, ElasticsearchCompletionField[]>>({});
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
const redisCommandDocsCache = ref<Record<string, RedisCommandDocumentation[]>>({});
const redisCommandDocsCacheGeneration = new Map<string, number>();
const mongoCompletionCollectionsCache = ref<Record<string, string[]>>({});
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
const schemaListCache = ref<Record<string, string[]>>({});
const sidebarSearchQuery = ref("");
const sidebarTableSearchQueries = ref<Record<string, string>>({});
// Local table indexes are immutable snapshots until an explicit refresh.
// Keep both resolved values and in-flight reads here so regex typing does
// not reread SQLite for every keypress or issue duplicate concurrent loads.
const sidebarTableSearchIndexCache = new Map<string, TableInfo[] | null>();
const sidebarTableSearchIndexInFlight = new Map<string, Promise<TableInfo[] | null>>();
const sidebarTableSearchIndexConnectionGenerations = new Map<string, number>();
const sidebarTableSearchIndexScopeGenerations = new Map<string, number>();
const sidebarTableSearchIndexPersistenceQueues = new Map<string, Promise<void>>();
const sidebarTableSearchIndexInvalidation = ref<{
revision: number;
scopes: Array<SidebarRegexScopeIdentity & { parentNodeId: string }>;
}>({ revision: 0, scopes: [] });
let sidebarTableSearchIndexManifest: TableSearchIndexManifestEntry[] | null = null;
let sidebarTableSearchIndexManifestInFlight: Promise<TableSearchIndexManifestEntry[]> | null = null;
let sidebarTableSearchIndexManifestWriteQueue: Promise<void> = Promise.resolve();
const sidebarTableNameFilters = ref<Record<string, TableNameFilter>>(loadSidebarTableNameFilters());
const sidebarTableNameFilterRevisions = new Map<string, number>();
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
const completionObjectIndex = new Map<string, { touched: number; objects: SqlCompletionObject[] }>();
const completionColumnIndex = new Map<string, { touched: number; columns: SqlCompletionColumn[] }>();
const completionColumnPrefixIndex = new Map<string, { touched: number; columns: SqlCompletionColumn[]; complete: boolean }>();
const completionForeignKeyIndex = new Map<string, { touched: number; foreignKeys: SqlCompletionForeignKey[] }>();
const completionInFlight = new Map<string, Promise<unknown>>();
const completionCacheRevisions = ref<Record<string, number>>({});
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; selectedRoutines?: string[]; preferredResultTab?: "tables" | "routines" } | null>(null);
const dataCompareSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName?: string;
} | null>(null);
const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string; preview?: SqlFilePreview } | null>(null);
const diagramSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName?: string;
tableNames?: 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 mongoDatabaseDumpSource = ref<{ connectionId: string; database: string; mode: "dump" | "restore" } | null>(null);
const mongoImportSource = ref<{
connectionId: string;
database: string;
collection: string;
} | null>(null);
const mongoImportCompleted = ref<{
connectionId: string;
database: string;
collection: string;
at: number;
} | 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<SidebarLayout>(emptyLayout());
const tableVGroupLayouts = ref<Record<string, TableVGroupLayout>>({});
const dirtyTableVGroupScopeKeys = new Set<string>();
let tableVGroupPersistTimer: ReturnType<typeof setTimeout> | null = null;
const connectionGroupPaths = computed(() => buildConnectionGroupPathMap(sidebarLayout.value));
const connectionGroupOptions = computed(() => connectionGroupDestinationRows(sidebarLayout.value));
const selectedConnectionGroupId = computed(() => {
const selectedNodeId = selectedTreeNodeId.value;
const selectedNode = selectedNodeId ? findNode(treeNodes.value, selectedNodeId) : null;
return connectionGroupIdForSelection(sidebarLayout.value, selectedNodeId, selectedNode?.connectionId);
});
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
const staleTreeRefreshIds = new Set<string>();
const activeTreeRefreshGenerations = new Map<string, number>();
let nextTreeRefreshGeneration = 0;
const metadataLoadCoordinator = new MetadataLoadCoordinator((event) => {
console.debug("[DBX][metadata-load:coordinator]", event);
});
const metadataListPageCache = new MetadataResultCache<MetadataListPageResult>({
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<string, Promise<void>>();
const disconnectInFlight = new Map<string, Promise<void>>();
const disconnectInFlightScoped = new Map<string, boolean>();
const cancelDisconnectInFlight = new Map<string, Promise<void>>();
const activeLocalConnectionAttempts = new Map<string, number>();
const cancelledLocalConnectionAttempts = new Map<string, Set<number>>();
const successfulLocalConnectionAttempts = new Map<string, number>();
const connectionStateRevisions = new Map<string, number>();
const connectionErrorRevisions = new Map<string, number>();
const tableListSourceRevisions = new Map<string, number>();
const treeNodeLoads = new TreeNodeLoadRegistry();
const filteredObjectGroupChildrenIds = new Set<string>();
// A remote sidebar search swaps an object group's children for a filtered
// projection. The pre-search children — including extra pages the user loaded
// through "load more" — are captured here so clearing the query restores the
// browsing state instead of silently dropping those pages back to page one.
const filteredObjectGroupChildrenSnapshots = new Map<string, { children: TreeNode[]; objectCount?: number }>();
const primaryVisibleObjectRefreshInFlight = new Set<string>();
let nextLocalConnectionAttempt = 0;
let beforeConnectHandler: BeforeConnectHandler | null = null;
let initFromDiskPromise: Promise<void> | 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<T>(scope: MetadataScopeInput, task: () => Promise<T>, options?: LoadTreeOptions): Promise<T> {
return metadataLoadCoordinator.run(scope, task, { force: options?.force, kind: scope.kind });
}
async function loadCachedMetadataListPage<T extends MetadataListPageResult>(scope: MetadataScopeInput, load: () => Promise<T>, options?: { force?: boolean }): Promise<T> {
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 getEtcdAccessCapabilities(connectionId: string): EtcdAccessCapabilities {
const config = getConfig(connectionId);
if (config?.db_type !== "etcd") return unrestrictedEtcdAccess;
const username = config.username?.trim() || "";
// Only an explicitly configured root user can be trusted before probing
// the server. Anonymous and certificate connections may still target an
// auth-enabled cluster, so keep privileged controls hidden until the
// agent reports the effective identity and authentication status.
if (username === "root") return unrestrictedEtcdAccess;
return etcdAccessCapabilities.value[connectionId] ?? (config.driver_profile === "etcd-v2" ? fallbackEtcdV2Access : restrictedEtcdAccess);
}
function canWriteEtcdKey(connectionId: string, key: string, keyBytes?: api.KvValue | null): boolean {
const access = getEtcdAccessCapabilities(connectionId);
if (!access.writable) return false;
if (access.writePermissions === null) return true;
return etcdPermissionsAllowKey(access.writePermissions, keyBytes ?? { encoding: "utf8", data: key });
}
async function resolveEtcdAccessCapabilities(connectionId: string, config: ConnectionConfig | undefined): Promise<EtcdAccessCapabilities> {
const username = config?.username?.trim() || "";
if (username === "root") return unrestrictedEtcdAccess;
// The v2 auth API cannot resolve the identity behind the active HTTP
// connection. Query an explicitly configured user, while anonymous v2
// connections retain their historical Key access and rely on the server
// as the authorization boundary.
if (config?.driver_profile === "etcd-v2" && !username) return fallbackEtcdV2Access;
// An empty user asks the agent for the authenticated identity. This also
// covers client-certificate CN authentication without duplicating X.509
// parsing in the UI.
const userParams = config?.driver_profile === "etcd-v2" ? { user: username } : {};
const user = await api.etcdAuthCall<api.EtcdAuthUserDetail>(connectionId, "user_get", userParams);
if (user.authEnabled === false) return unrestrictedEtcdAccess;
if (user.roles.includes("root")) return unrestrictedEtcdAccess;
const roles = await Promise.all(user.roles.map((role) => api.etcdAuthCall<api.EtcdAuthRoleDetail>(connectionId, "role_get", { role })));
const writePermissions = roles.flatMap((role) => role.permissions.filter((permission) => permission.access === "write" || permission.access === "readwrite"));
return {
admin: false,
writable: writePermissions.length > 0,
writePermissions,
};
}
async function ensureEtcdAccessCapabilities(connectionId: string, options: { force?: boolean; verifyHealth?: boolean } = {}): Promise<EtcdAccessCapabilities> {
const config = getConfig(connectionId);
if (config?.db_type !== "etcd") return unrestrictedEtcdAccess;
const cached = etcdAccessCapabilities.value[connectionId];
if (cached && !options.force) return cached;
const existing = etcdAccessCapabilityLoads.get(connectionId);
if (existing) return existing;
const load = (async () => {
await ensureConnected(connectionId, { verifyHealth: options.verifyHealth ?? false });
const currentConfig = getConfig(connectionId);
if (currentConfig?.db_type !== "etcd") return unrestrictedEtcdAccess;
const generation = etcdAccessCapabilityGenerations.get(connectionId) ?? 0;
const configFingerprint = connectionConfigFingerprint(currentConfig);
let access: EtcdAccessCapabilities;
try {
access = await resolveEtcdAccessCapabilities(connectionId, currentConfig);
} catch {
// Initial v3 discovery fails closed. Once a capability snapshot has
// been confirmed, however, a transient Auth RPC failure must not
// overwrite it and make controls flicker between states.
// The v2 API cannot discover an implicit current user, so preserve its
// pre-capability behavior on discovery errors and let etcd authorize
// each Key mutation. Administrative views remain hidden.
access = etcdAccessCapabilities.value[connectionId] ?? (currentConfig.driver_profile === "etcd-v2" ? fallbackEtcdV2Access : restrictedEtcdAccess);
}
if (generation === (etcdAccessCapabilityGenerations.get(connectionId) ?? 0) && connectedIds.value.has(connectionId) && connectionConfigFingerprint(getConfig(connectionId) ?? currentConfig) === configFingerprint) {
etcdAccessCapabilities.value = { ...etcdAccessCapabilities.value, [connectionId]: access };
return access;
}
return getEtcdAccessCapabilities(connectionId);
})();
etcdAccessCapabilityLoads.set(connectionId, load);
try {
return await load;
} finally {
if (etcdAccessCapabilityLoads.get(connectionId) === load) etcdAccessCapabilityLoads.delete(connectionId);
}
}
function clearEtcdAccessCapabilities(connectionId: string) {
etcdAccessCapabilityGenerations.set(connectionId, (etcdAccessCapabilityGenerations.get(connectionId) ?? 0) + 1);
etcdAccessCapabilityLoads.delete(connectionId);
if (connectionId in etcdAccessCapabilities.value) {
const next = { ...etcdAccessCapabilities.value };
delete next[connectionId];
etcdAccessCapabilities.value = next;
}
}
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 databaseCompatibilityKey(connectionId: string, database: string): string {
return `${connectionId}\u0000${database.trim().toLowerCase()}`;
}
function databaseCompatibilityModeFromTree(connectionId: string, database: string): string | undefined {
const wanted = database.trim().toLowerCase();
const visit = (nodes: readonly TreeNode[]): string | undefined => {
for (const node of nodes) {
if (node.connectionId === connectionId && node.type === "database" && node.database?.trim().toLowerCase() === wanted) {
return node.compatibilityMode?.trim() || undefined;
}
const nested = node.children ? visit(node.children) : undefined;
if (nested) return nested;
}
return undefined;
};
return visit(treeNodes.value);
}
/** Compatibility mode for one database. Live metadata wins over persisted tree data. */
function databaseCompatibilityMode(connectionId: string | undefined, database: string | undefined): string | undefined {
if (!connectionId || database == null) return undefined;
return databaseCompatibilityModes.value[databaseCompatibilityKey(connectionId, database)] ?? databaseCompatibilityModeFromTree(connectionId, database);
}
function setDatabaseCompatibilityModesFromDatabases(connectionId: string, databases: readonly { name: string; compatibility_mode?: string | null }[]) {
if (!connectionId) return;
const prefix = `${connectionId}\u0000`;
const next = { ...databaseCompatibilityModes.value };
// Prune entries for databases that no longer exist on this connection.
let changed = false;
for (const key of Object.keys(next)) {
if (key.startsWith(prefix) && !databases.some((database) => databaseCompatibilityKey(connectionId, database.name.trim()) === key)) {
delete next[key];
changed = true;
}
}
for (const database of databases) {
const name = database.name.trim();
if (!name) continue;
const mode = database.compatibility_mode?.trim() || undefined;
const key = databaseCompatibilityKey(connectionId, name);
if (mode !== undefined && next[key] !== mode) {
next[key] = mode;
changed = true;
} else if (mode === undefined && key in next) {
delete next[key];
changed = true;
}
}
if (changed) databaseCompatibilityModes.value = next;
}
/** Ensure the compatibility map is warm for an openGauss connection even when
* the database tree has not loaded (restored tab, filtered node, …). Runs in
* the background (void caller) and is timeout-bounded so it never blocks or
* hangs the connect flow. The connection-state revision guards against stale
* refreshes overwriting a newer connection's map after a disconnect/reconnect. */
async function refreshConnectionDatabaseModesOnce(connectionId: string, config: ConnectionConfig) {
try {
if (config.db_type !== "opengauss") return;
const revision = connectionStateRevision(connectionId);
const databases = await withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "compatibility modes").catch(() => undefined);
if (!databases || !isCurrentConnectionStateRevision(connectionId, revision)) return;
const before = databaseCompatibilityModes.value;
setDatabaseCompatibilityModesFromDatabases(connectionId, databases);
if (databaseCompatibilityModes.value === before) return;
// The mode map changed; invalidate completion caches for every affected
// database. The completion cache key does not include the compatibility
// mode, so a result cached while the mode was unknown/wrong (e.g. package
// members surfaced as top-level routines) would otherwise be served forever.
for (const database of databases) {
invalidateCompletionCache(connectionId, database.name.trim());
}
// Refresh any expanded openGauss database subtree so PACKAGE groups appear
// without waiting for the next manual expand.
for (const database of databases) {
if (!database.compatibility_mode?.trim()) continue;
if (database.compatibility_mode.trim().toUpperCase() !== "A") continue;
const node = findNode(treeNodes.value, `${connectionId}:${database.name.trim()}`);
if (!node || node.type !== "database" || !node.isExpanded) continue;
void refreshTreeNode(node).catch(() => undefined);
}
} catch {
// Compatibility metadata is optional and must never create an unhandled
// rejection in the background connection lifecycle.
}
}
function refreshConnectionDatabaseModes(connectionId: string, config: ConnectionConfig): Promise<void> {
const existing = databaseCompatibilityRefreshes.get(connectionId);
if (existing) return existing;
const refresh = refreshConnectionDatabaseModesOnce(connectionId, config).finally(() => {
if (databaseCompatibilityRefreshes.get(connectionId) === refresh) databaseCompatibilityRefreshes.delete(connectionId);
});
databaseCompatibilityRefreshes.set(connectionId, refresh);
return refresh;
}
async function ensureDatabaseCompatibilityMode(connectionId: string, database: string | undefined): Promise<string | undefined> {
const config = getConfig(connectionId);
if (config?.db_type !== "opengauss" || database == null) return undefined;
const current = databaseCompatibilityMode(connectionId, database);
if (current !== undefined) return current;
await refreshConnectionDatabaseModes(connectionId, { ...config, id: connectionId });
return databaseCompatibilityMode(connectionId, database);
}
function clearConnectionIdentifierQuote(connectionId: string) {
clearConnectionDatabaseModes(connectionId);
if (!(connectionId in identifierQuotes.value)) return;
const next = { ...identifierQuotes.value };
delete next[connectionId];
identifierQuotes.value = next;
}
function clearConnectionDatabaseModes(connectionId: string) {
const prefix = `${connectionId}\u0000`;
const entries = Object.entries(databaseCompatibilityModes.value).filter(([key]) => !key.startsWith(prefix));
if (entries.length !== Object.keys(databaseCompatibilityModes.value).length) {
databaseCompatibilityModes.value = Object.fromEntries(entries);
}
}
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<number>();
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<void> | undefined {
return disconnectInFlightScoped.get(connectionId) ? undefined : disconnectInFlight.get(connectionId);
}
/** True while any disconnect for the connection is in flight (full or scoped). */
function hasDisconnectInFlight(connectionId: string): boolean {
return disconnectInFlight.has(connectionId);
}
async function waitForBlockingDisconnectInFlight(connectionId: string): Promise<void> {
const pending = getBlockingDisconnectInFlight(connectionId);
if (pending) await pending;
}
function trackDisconnectRequest(connectionId: string, request: Promise<void>, scoped: boolean): Promise<void> {
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<void> {
const clientAttempt = activeLocalConnectionAttempts.get(connectionId) ?? successfulLocalConnectionAttempts.get(connectionId);
let request: Promise<void>;
try {
request = api.disconnectDb(connectionId, clientAttempt);
} catch (error) {
request = Promise.reject(error);
}
return trackDisconnectRequest(connectionId, request, clientAttempt != null);
}
/**
* One-time connections are never persisted, so the backend's "not in the saved
* list, so reclaim it" branch in `sync_connection_configs` never fires for them
* (see dbx-core `should_retain_runtime_config`) and `disconnect_db` is the only
* reclaim point. Removing one must disconnect it explicitly, or its runtime
* config, pool, and tunnel live until the process exits.
*
* `clientAttempt` is deliberately omitted: removal is terminal, so a superseded
* attempt number must not skip the cleanup.
*/
function releaseOneTimeRuntimeConnections(connectionIds: string[]) {
for (const connectionId of connectionIds) {
let request: Promise<void>;
try {
request = api.disconnectDb(connectionId);
} catch (error) {
request = Promise.reject(error);
}
void trackDisconnectRequest(connectionId, request, false).catch(() => {});
}
}
/**
* A one-time connection is deleted outright rather than left behind for a
* reconnect, so its runtime record has nothing left to point at.
*
* Tab handling is deliberately NOT done here: `applyDeletedConnectionTabHandling`
* already applied `deleteConnectionTabHandlingMode` to these connections, so
* force-closing here would silently override the user's policy (and discard
* unsaved SQL drafts) — exactly what that setting exists to prevent.
*/
async function cleanupRemovedOneTimeConnections(connectionIds: string[]) {
releaseOneTimeRuntimeConnections(connectionIds);
}
/**
* 删除连接后的页签处理:按 `deleteConnectionTabHandlingMode` 决定保留哪些 SQL 页签,
* 并在开启「记住连接名与数据库」时记录 连接名 → { 数据库名, 类型 },供新建同名连接回填与重绑。
*
* `one_time` 临时连接同样走这套策略:它的页签虽然无法再执行,但里面的 SQL 文本是用户的工作,
* 不该被静默丢弃。
*
* 只排除可见 schema 选择器的临时草稿连接——它们只活在弹窗交互期间,不会出现在页签里。
* 空名连接无法按名字重绑,只跳过「记住连接名」;页签本身仍要按策略关闭/保留,
* 否则会留下指向已删除连接的孤儿页签。
*/
async function applyDeletedConnectionTabHandling(configs: readonly ConnectionConfig[]) {
const targets = configs.filter((config) => !isDraftVisibleSchemasConnectionId(config.id));
if (!targets.length) return;
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
const keep = deletedConnectionTabKeepMode(settingsStore.editorSettings.deleteConnectionTabHandlingMode);
const remember = settingsStore.editorSettings.rememberConnectionDatabaseOnDelete;
const namedTargets = targets.filter((config) => config.name.trim() !== "");
if (remember && namedTargets.length) settingsStore.rememberConnectionDatabases(namedTargets.map((config) => [config.name, config.database, config.db_type] as const));
for (const config of targets) {
queryStore.detachConnectionTabsForDelete(config.id, { keep, connectionName: remember && config.name.trim() !== "" ? config.name : undefined });
}
// 页签处理是同步的内存操作,但落盘是防抖的。这里立即冲刷,避免随后重启恢复出
// 已被策略关闭的页签。
await queryStore.flushPendingPersist().catch(() => undefined);
}
function cancelDisconnectKey(connectionId: string, attempt: number): string {
return `${connectionId}:${attempt}`;
}
function startCancelDisconnectRequest(connectionId: string, attempt: number): Promise<void> {
const key = cancelDisconnectKey(connectionId, attempt);
const existing = cancelDisconnectInFlight.get(key);
if (existing) return existing;
let request: Promise<void>;
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, { ssh: connectionUsesSsh(config) })) return message;
return appendAgentDriverUpdateHint(message, agentDriverUpdateHint());
}
function refreshAgentDriversForErrorHint(): Promise<void> {
if (agentDriversRefreshPromise) return agentDriversRefreshPromise;
agentDriversRefreshPromise = api
.listInstalledAgents()
.then((drivers) => {
agentDrivers.value = drivers;
})
.catch(() => undefined)
.finally(() => {
agentDriversRefreshPromise = null;
});
return agentDriversRefreshPromise;
}
function refreshLocalAgentDrivers(): Promise<void> {
if (localAgentDriversRefreshPromise) return localAgentDriversRefreshPromise;
localAgentDriversRefreshPromise = api
.listInstalledAgentsLocal()
.then((drivers) => {
agentDrivers.value = drivers;
})
.catch(() => undefined)
.finally(() => {
localAgentDriversRefreshPromise = null;
});
return localAgentDriversRefreshPromise;
}
async function supportsXuguTableChildMetadata(): Promise<boolean> {
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;
}
/**
* Forgets warm-up bookkeeping for a connection. The backend pool is gone once a
* connection is torn down, so a remembered timestamp must not make
* `warmConnection` skip a warm-up that the next execution would then pay for.
*/
function clearConnectionPrewarmState(connectionId: string) {
const prefix = `${connectionId}\u0000`;
for (const key of Object.keys(lastConnectionPrewarmAt.value)) {
if (key.startsWith(prefix)) delete lastConnectionPrewarmAt.value[key];
}
for (const key of connectionPrewarmInFlight.keys()) {
if (key.startsWith(prefix)) connectionPrewarmInFlight.delete(key);
}
}
function connectionPrewarmKey(connectionId: string, target: { database?: string; catalog?: string; clientSessionId?: string }) {
return [connectionId, target.database ?? "", target.catalog ?? "", target.clientSessionId ?? ""].join("\u0000");
}
/**
* Warm the driver and connection pool for a connection a tab is about to use.
*
* Opening a SQL editor tab used to do nothing until the user pressed Run, so
* the first execution paid pool creation, tunnel setup, and — for externally
* driven databases such as Oracle — JDBC driver/agent startup. Those seconds
* were visible in the loading indicator but are outside the statement timer,
* which is why the summary could read "23ms" after a multi-second wait.
* Warming in the background moves that cost off the critical path. Failures
* are ignored here because the real request reports them with full context.
*/
function warmConnection(connectionId: string, target: { database?: string; catalog?: string; clientSessionId?: string } = {}) {
if (!connectionId || !connectedIds.value.has(connectionId)) return;
const key = connectionPrewarmKey(connectionId, target);
const warmedAt = lastConnectionPrewarmAt.value[key];
if (typeof warmedAt === "number" && Date.now() - warmedAt < CONNECTION_PREWARM_TTL_MS) return;
if (connectionPrewarmInFlight.has(key)) return;
const promise = api
.prewarmConnection(connectionId, target.database, target.catalog, target.clientSessionId)
.then(() => {
lastConnectionPrewarmAt.value[key] = Date.now();
})
.catch(() => {
// A failed warm-up is not a user-facing error: the next real request
// rebuilds the pool and surfaces any genuine failure itself.
})
.finally(() => {
connectionPrewarmInFlight.delete(key);
});
connectionPrewarmInFlight.set(key, promise);
void promise;
}
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<void>): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, 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<T>(connectionId: string, promise: Promise<T>, label: string): Promise<T> {
const timeoutMs = metadataLoadTimeoutMs(getConfig(connectionId));
const errorRevision = connectionErrorRevision(connectionId);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
promise,
new Promise<never>((_, 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<void>): Promise<void> {
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
void promise.catch((error) => {
if (timedOut) console.warn("[DBX][connection:disconnect-late-error]", { connectionId, error });
});
try {
await Promise.race([
promise,
new Promise<void>((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);
clearEtcdAccessCapabilities(connectionId);
clearSidebarStorageCaches(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionIdentifierQuote(connectionId);
clearConnectionNodeLoading(connectionId);
clearConnectionHealthCheck(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
recordConnectionError(connectionId, error);
// 被动断链同样是元数据生命周期边界:查询/健康检查发现连接已失效后,
// 不得继续把旧 tableMeta 当作当前代次的 warm cache(PR #6640 blocker 3)。
invalidateConnectionMetadataLifetime(connectionId);
}
function recordConnectionLostError(connectionId: string, error: unknown): boolean {
if (shouldMarkDisconnected(error)) {
markConnectionLost(connectionId, error);
return true;
}
return false;
}
/**
* 侧边栏搜索是后台投影:搜索为了读取元数据而被动重连失败时,不能把驱动的原始错误
* (旧版 SQL Server 可能是一整段 TLS/加密提示)留在连接节点上,也不能每输入一个字就重试一次。
* 搜索驱动的加载在这里登记,ensureConnected() 据此识别并降级这类失败。
*/
const sidebarSearchLoadCounts = new Map<string, number>();
function isSidebarSearchLoad(connectionId: string): boolean {
return (sidebarSearchLoadCounts.get(connectionId) ?? 0) > 0;
}
async function withSidebarSearchLoad<T>(connectionId: string | null | undefined, work: () => Promise<T>): Promise<T> {
const id = connectionId?.trim();
if (!id) return work();
sidebarSearchLoadCounts.set(id, (sidebarSearchLoadCounts.get(id) ?? 0) + 1);
try {
return await work();
} finally {
const remaining = (sidebarSearchLoadCounts.get(id) ?? 1) - 1;
if (remaining > 0) sidebarSearchLoadCounts.set(id, remaining);
else sidebarSearchLoadCounts.delete(id);
}
}
function sidebarSearchSkipMessage(error: unknown): string {
const firstLine =
connectionErrorMessage(error)
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0) ?? "";
const detail = firstLine.length > 240 ? `${firstLine.slice(0, 240)}…` : firstLine;
return i18n.global.t("sidebar.searchConnectionSkipped", { message: detail });
}
/**
* 后台搜索重连失败:按被动断链处理(连接确实不可用了),但把驱动错误换成一行提示,
* 这样节点上只留一句“搜索已跳过该连接”,用户显式连接时仍能看到完整错误。
* markLost=false:查询阶段连接仍然存活(如权限拒绝),只留一行跳过提示,
* 不替用户断开正在使用的连接。
*/
function recordSidebarSearchConnectionFailure(connectionId: string, error: unknown, markLost = true) {
if (markLost) markConnectionLost(connectionId, error);
setConnectionError(connectionId, sidebarSearchSkipMessage(error));
}
// 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;
// 搜索驱动的后台加载失败按“搜索跳过该连接”降级:搜索只是投影动作,不能把驱动原始错误
// (旧版 SQL Server 会附带整段 TLS/加密提示)留在连接节点上。取消/被取代的尝试沿用原有清错处理。
if (isSidebarSearchLoad(connectionId) && !isCancelledConnectionAttempt(error) && !isSupersededConnectionAttempt(error)) {
// 元数据查询失败未必意味着连接已死:只有连接级错误(与 recordConnectionLostError 同一判定)
// 才被动断链;权限拒绝等查询期错误保持连接,只降级为一行跳过提示。
recordSidebarSearchConnectionFailure(connectionId, error, shouldMarkDisconnected(error));
return;
}
if (recordConnectionLostError(connectionId, error)) return;
recordConnectionError(connectionId, error);
}
async function runConnectionTreeMetadataLoad<T>(connectionId: string, node: TreeNode | null | undefined, work: (load: TreeNodeLoadHandle) => Promise<T>): Promise<T | undefined> {
if (!node) return;
let load = beginTreeNodeLoad(node);
try {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
return await work(load);
} catch (error) {
recordMetadataLoadError(connectionId, error, load);
throw error;
} finally {
finishTreeNodeLoad(load);
}
}
async function withConnectionAttemptTimeout<T>(promise: Promise<T>, config: ConnectionConfig): Promise<T> {
const timeoutMs = connectionAttemptTimeoutMs(config, tunnelProfileStore.profileById);
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | 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<never>((_, 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<string, string> = {
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",
meilisearch: "Meilisearch",
solr: "Apache Solr",
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",
kyuubi: "Apache Kyuubi",
impala: "Apache Impala",
spark: "Apache Spark",
db2: "DB2",
informix: "Informix",
phoenix: "Apache Phoenix",
neo4j: "Neo4j",
cassandra: "Cassandra",
bigquery: "BigQuery",
spanner: "Cloud Spanner",
kylin: "Kylin",
ignite: "Apache Ignite",
ignite3: "Apache Ignite 3",
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 ?? DEFAULT_QUERY_TIMEOUT_SECS),
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),
redis_key_templates: (() => {
const templates = normalizeRedisKeyTemplates(config.redis_key_templates);
return templates.length > 0 ? templates : undefined;
})(),
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<string[]> {
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<T>(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 {
// dameng-users / dameng-roles must be here too: they are synthesized admin
// nodes (fixed ids, rebuilt on every re-normalization). If they were treated
// as metadata children, withConnectionUtilityNodes would keep the old copies
// AND append fresh ones on every useCachedChildren pass, duplicating the
// 用户/角色 menus once per refresh cycle.
return node.type === "oracle-db-links" || node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "group-tablespaces" || node.type === "saved-sql-root";
}
function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] {
return (children || []).filter((child) => !isConnectionUtilityNode(child));
}
function hasConnectionMetadataChildren(children: TreeNode[] | undefined): boolean {
return connectionMetadataChildren(children).length > 0;
}
// `trustEmptyMetadataChildren` distinguishes two very different reasons this
// reload's metadata children can come back empty: the backend's raw fetch
// returned nothing at all (still ambiguous/possibly transient — keep the
// stale-preserve protection below), vs. the raw fetch had data but our own
// visible-databases/visible-schemas filter deterministically reduced it to
// zero (e.g. the only visible database was just dropped) — that emptiness
// is legitimate and must not be overridden by stale cached children.
function preserveExistingConnectionMetadataChildren(parent: TreeNode, children: TreeNode[], trustEmptyMetadataChildren = false): TreeNode[] {
if (parent.type !== "connection" || hasConnectionMetadataChildren(children)) return children;
if (trustEmptyMetadataChildren) 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<TreeNode["type"]>(["column", "index", "fkey", "trigger", "event", "type-member"]);
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);
}
}
// Forget both the filtered marker and its captured pre-search children, so a
// later restore cannot resurrect a stale projection or an outdated list.
function forgetFilteredObjectGroupChildren(nodeId: string) {
filteredObjectGroupChildrenIds.delete(nodeId);
filteredObjectGroupChildrenSnapshots.delete(nodeId);
}
/** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */
function forgetTreeNodeLoadState(nodeId: string, options?: { deletePersisted?: boolean }) {
forgetFilteredObjectGroupChildren(nodeId);
clearLoadedChildrenCache(nodeId, options);
treeNodeLoads.invalidatePrefix(nodeId);
}
function syncConfirmedEmptyTreeNodeId(parent: TreeNode) {
if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return;
const childCount = parent.children?.filter((child) => child.type !== "saved-sql-root").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 dedupeTreeNodeChildrenById(children: TreeNode[]): TreeNode[] {
// Keep the LAST occurrence of each id (reverse pass): if a stale copy was
// preserved ahead of a freshly synthesized node with the same id, the fresh
// node — carrying the current members/data — is the one that must survive.
const seen = new Set<string>();
const deduped: TreeNode[] = [];
for (let index = children.length - 1; index >= 0; index -= 1) {
const child = children[index];
if (seen.has(child.id)) continue;
seen.add(child.id);
deduped.push(child);
}
deduped.reverse();
return deduped;
}
function setChildren(parent: TreeNode, children: TreeNode[], options?: { trustEmptyConnectionChildren?: boolean }) {
// Compare markers against the resolved child list (after connection preserve), not the raw loader payload.
children = preserveExistingConnectionMetadataChildren(parent, children, options?.trustEmptyConnectionChildren === true);
children = decorateDatabaseSavedSqlTreeNodes(children, savedSqlFilesByDatabase, parent.children);
if (parent.type === "database") {
children = withDatabaseSavedSqlRoot(parent, children, savedSqlFilesByDatabase);
}
// Defensive: sibling ids must be unique (RecycleScroller keys rows by id).
// The Dameng users/roles admin nodes were once re-appended by every
// re-normalization, leaving duplicate rows in the sidebar; last-wins keeps
// the freshly synthesized copy so such a regression can never surface as
// repeated menus or stale member data.
children = dedupeTreeNodeChildrenById(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));
// Discarded shells collect here so their persisted-cache deletes can be
// aggregated into one prefix request per ancestor (issue #9779).
const discardedIds: string[] = [];
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, { deletePersisted: false });
discardedIds.push(oldId);
}
}
children = children.map((child) => {
const old = oldMap.get(child.id);
// Virtual group containers are rebuilt from the layout on every projection;
// stale copies must never override the freshly computed arrangement.
if (child.type === "table-vgroup") return child;
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, { deletePersisted: false });
discardedIds.push(child.id);
}
return child;
});
deletePersistedTreeCachesForDiscardedDescendants(parent.id, discardedIds);
}
const migratedPins = migrateLegacyPinnedTreeNodeOrder(children, pinnedTreeNodeOrder.value);
if (migratedPins.changed) {
setPinnedTreeNodeOrder(migratedPins.order);
persistPinnedTreeNodeIds();
}
syncPinnedTreeState(children);
const vgroupResolved = resolveTableVGroupScope(parent);
children = applyTableVGroupsToChildren(children, vgroupResolved ? tableVGroupLayouts.value[vgroupResolved.scopeKey] : undefined, vgroupResolved?.scope ?? parent);
parent.children = markRawLeafTreeNodes(children);
loadedTreeNodeChildrenIds.value.add(parent.id);
syncConfirmedEmptyTreeNodeId(parent);
}
function setLoadedTableMetadataChildren(parent: TreeNode, children: TreeNode[]) {
setChildren(parent, children);
parent.objectCount = parent.children!.length;
}
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 buildDamengUserNode(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-users");
return {
id: `${connectionId}:__dameng_users`,
label: "tree.damengUsers",
type: "dameng-users",
connectionId,
database: "",
isExpanded: existing?.isExpanded ?? false,
};
}
function buildDamengRoleNode(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-roles");
return {
id: `${connectionId}:__dameng_roles`,
label: "tree.damengRoles",
type: "dameng-roles",
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 buildXuguTablespacesNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
const config = getConfig(connectionId);
if (effectiveDatabaseTypeForConnection(config) !== "xugu") return undefined;
const existing = existingConnectionNode?.children?.find((child) => child.type === "group-tablespaces");
return {
id: `${connectionId}:__xugu_tablespaces`,
label: "tree.xuguTablespaces",
type: "group-tablespaces",
connectionId,
// SYS_TABLESPACES/SYS_DATAFILES are scoped to the current database. The
// optional value lets the agent switch to the configured database while
// retaining compatibility with connections that have no default DB.
database: config?.database || "",
objectCount: existing?.objectCount,
isExpanded: existing?.isExpanded ?? false,
children: existing?.children ?? [],
};
}
function buildOracleDatabaseLinksNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
const config = getConfig(connectionId);
if (!supportsOracleDatabaseLinks(effectiveDatabaseTypeForConnection(config))) return undefined;
const existing = existingConnectionNode?.children?.find((child) => child.type === "oracle-db-links");
return { ...existing, id: `${connectionId}:__oracle_db_links`, label: "tree.databaseLinks", type: "oracle-db-links", connectionId, database: config?.database || "", isExpanded: existing?.isExpanded ?? false, children: existing?.children ?? [] };
}
async function listOracleDatabaseLinks(connectionId: string, database: string) {
const databaseType = effectiveDatabaseTypeForConnection(getConfig(connectionId));
if (!supportsOracleDatabaseLinks(databaseType)) return [];
await ensureConnected(connectionId);
return oracleDatabaseLinksFromResult(await api.executeQuery(connectionId, database, oracleDatabaseLinksSql(databaseType), undefined, undefined, { maxRows: 10000, timeoutSecs: 15 }));
}
async function refreshOracleDatabaseLinks(connectionId: string) {
const root = findNode(treeNodes.value, `${connectionId}:__oracle_db_links`);
if (root) await loadOracleDatabaseLinks(root, { force: true });
}
async function loadOracleDatabaseLinks(node: TreeNode, options?: LoadTreeOptions) {
if (!node.connectionId) return;
await runConnectionTreeMetadataLoad(node.connectionId, node, async (load) => {
if (useCachedChildren(node, options, load)) return;
const links = await listOracleDatabaseLinks(node.connectionId!, node.database || "");
const target = treeNodeLoadTarget(load);
if (!target) return;
setChildren(
target,
links.map((link) => ({
id: `${node.id}:${encodeURIComponent(link.owner)}:${encodeURIComponent(link.name)}`,
label: link.name,
type: "oracle-db-link" as const,
connectionId: node.connectionId,
database: node.database,
schema: link.owner,
comment: `${link.owner} · ${link.username} · ${link.host}`,
isExpanded: false,
})),
);
target.objectCount = links.length;
target.isExpanded = true;
});
}
function withConnectionUtilityNodes(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
const nonUtilityChildren = connectionMetadataChildren(children);
const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode);
const damengUserNode = buildDamengUserNode(connectionId, existingConnectionNode);
const damengRoleNode = buildDamengRoleNode(connectionId, existingConnectionNode);
const damengJobAdminNode = buildDamengJobAdminNode(connectionId, existingConnectionNode);
const xuguTablespacesNode = buildXuguTablespacesNode(connectionId, existingConnectionNode);
return [...nonUtilityChildren, buildOracleDatabaseLinksNode(connectionId, existingConnectionNode), userAdminNode, damengUserNode, damengRoleNode, damengJobAdminNode, xuguTablespacesNode].filter(Boolean) as TreeNode[];
}
function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
return withConnectionUtilityNodes(connectionId, children, existingConnectionNode);
}
function refreshDatabaseSavedSqlTrees(nodes: TreeNode[] = treeNodes.value) {
for (const node of nodes) {
if (node.type === "database") {
node.children = withDatabaseSavedSqlRoot(node, node.children || [], savedSqlFilesByDatabase);
}
if (node.children) refreshDatabaseSavedSqlTrees(node.children);
}
}
watch(
() => savedSqlStore.treeVersion,
() => {
savedSqlFilesByDatabase = indexSavedSqlFilesByDatabase(savedSqlStore.allFiles);
refreshDatabaseSavedSqlTrees();
},
{ flush: "post" },
);
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, database?: string): DatabaseObjectTreeKind[] {
const dbType = effectiveDatabaseTypeForConnection(config);
return sidebarObjectKindsForDatabase(dbType, databaseCompatibilityMode(config?.id, database));
}
function sidebarObjectTypesForScope(config: ConnectionConfig | undefined, database: string | undefined, schema?: string): DatabaseObjectTreeKind[] {
if (config?.db_type === "xugu" && isXuguPublicSynonymScope(schema)) {
return ["SYNONYM"];
}
if (config?.db_type === "xugu" && isXuguSchedulerJobScope(schema)) {
return ["JOB"];
}
return supportedSidebarObjectTypes(config, database);
}
function objectTreeCacheVersion(config: ConnectionConfig | undefined, database: string | undefined, schema: string | undefined, baseVersion: string): string {
let scopedVersion = config?.db_type === "xugu" && isXuguPublicSynonymScope(schema) ? `${baseVersion}-public-synonyms` : config?.db_type === "xugu" && isXuguSchedulerJobScope(schema) ? `${baseVersion}-scheduler-jobs` : baseVersion;
if (config?.db_type === "opengauss" && databaseCompatibilityMode(config.id, database)?.trim().toUpperCase() === "A") {
scopedVersion = `${scopedVersion}-a-packages-v1`;
}
return ownerAwareMetadataCacheVersion(config, scopedVersion);
}
/**
* The blank guard drops junk entries reported by drivers that pad their schema list. Cloud Spanner
* is the exception: in GoogleSQL the empty string *is* the name of the user schema, so dropping it
* hides every table that is not in a named schema. `keepBlankSchema` is therefore opt-in per
* database type rather than a global relaxation.
*/
function sortSidebarSchemaInfos(schemas: readonly SchemaInfo[], keepBlankSchema = false): SchemaInfo[] {
const byName = new Map<string, SchemaInfo>();
for (const schema of schemas) {
const name = schema.name.trim();
if (!name && !keepBlankSchema) continue;
byName.set(name, { name, comment: schema.comment ?? null });
}
return sortXuguSchemaInfos([...byName.values()], compareSidebarNames);
}
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 objectTreeProfileCacheKey = driverProfileObjectTreeProfileForConnection(config)?.cacheKey;
// objects-v9: table nodes now carry canonical tableName metadata for
// table-scoped plugin context; older cached nodes lack that identity.
const baseCacheVersion = objectTreeCacheVersion(config, node.database, node.schema, config?.db_type === "oracle" ? "objects-v8" : "objects-v9");
const cacheVersion = objectTreeProfileCacheKey ? `${baseCacheVersion}:${objectTreeProfileCacheKey}` : baseCacheVersion;
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 effectiveTableNameFilterForNode(node: TreeNode, userFilter?: TableNameFilter): TableNameFilter | undefined {
const config = node.connectionId ? getConfig(node.connectionId) : undefined;
const profileFilter = driverProfileObjectTreeProfileForConnection(config)?.groupOverrides.find((group) => group.nodeType === node.type)?.tableNameFilter;
if (!profileFilter) return userFilter;
if (!userFilter) return profileFilter;
return normalizeTableNameFilter({
// Dedicated groups own their include range. User-defined include patterns
// continue to apply to the default tables group, whose profile rule is an exclusion.
includePatterns: profileFilter.includePatterns.length > 0 ? profileFilter.includePatterns : userFilter.includePatterns,
excludePatterns: [...profileFilter.excludePatterns, ...userFilter.excludePatterns],
});
}
function tableListSourceRevision(connectionId: string): number {
return tableListSourceRevisions.get(connectionId) ?? 0;
}
function bumpTableListSourceRevision(connectionId: string): void {
tableListSourceRevisions.set(connectionId, tableListSourceRevision(connectionId) + 1);
}
function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined, sourceRevision?: number): MetadataScopeInput["extra"] {
return {
...(sourceRevision === undefined ? {} : { tableListSourceRevision: sourceRevision }),
...(filter
? {
tableNameFilterInclude: filter.includePatterns,
tableNameFilterExclude: filter.excludePatterns,
}
: {}),
};
}
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<string>) {
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,
};
}
// 连接元数据代次(connection generation):disconnect / 关闭数据库 / 后端死池
// 重连等生命周期边界会递增代次。数据标签页记录其 tableMeta 写入时的代次
// (QueryTab.tableMetaGeneration),代次失配视同冷缓存——即使位于 30s TTL
// 窗口内也必须重新拉取结构,且失效前启动的 in-flight 元数据请求不得把旧
// 结果写回 tab(issue #6623 / PR #6640 review blocker 1)。
// 语义:连接级代次影响该连接全部数据库;数据库级代次只影响单个数据库。
// 查询代次时取两者较新者,disconnect 只 bump 连接级、closeDatabaseConnection
// 只 bump 数据库级,因此作用域天然隔离。
let metadataGenerationCounter = 0;
const metadataGenerationByConnection = new Map<string, number>();
const metadataGenerationByDatabase = new Map<string, number>();
function metadataGenerationFor(connectionId: string, database?: string): number {
const connectionGeneration = metadataGenerationByConnection.get(connectionId) ?? 0;
if (database == null) return connectionGeneration;
return Math.max(connectionGeneration, metadataGenerationByDatabase.get(`${connectionId}\x00${database}`) ?? 0);
}
function bumpMetadataGeneration(connectionId: string, database?: string) {
metadataGenerationCounter += 1;
if (database == null) {
metadataGenerationByConnection.set(connectionId, metadataGenerationCounter);
} else {
metadataGenerationByDatabase.set(`${connectionId}\x00${database}`, metadataGenerationCounter);
}
}
/**
* 连接/数据库生命周期失效:shared metadata cache + 单调代次必须同步完成,
* 这样紧随其后的 toolbar reload 立刻看到 generation mismatch,不会把旧
* tableMeta 当成 warm。data-tab freshness 清戳经动态 import,避免与
* queryStore 循环依赖;generation 已变时 freshness 稍晚清除也安全。
*/
function invalidateConnectionMetadataLifetime(connectionId: string, database?: string) {
invalidateMetadataCaches({ connectionId, database });
bumpMetadataGeneration(connectionId, database);
void import("@/stores/queryStore").then(({ useQueryStore }) => {
useQueryStore().staleConnectionDataTabMetadata(connectionId, database);
});
}
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, options?: { skipObjectCacheInvalidation?: boolean }) {
if (!node.connectionId) return;
const isObjectLeaf =
node.type === "procedure" ||
node.type === "function" ||
node.type === "sequence" ||
node.type === "synonym" ||
node.type === "event" ||
node.type === "job" ||
node.type === "package" ||
node.type === "package-body" ||
node.type === "type" ||
node.type === "type-body" ||
node.type === "trigger";
const tableName = isObjectLeaf ? node.objectName || node.label : node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
const match = {
connectionId: node.connectionId,
database: node.database || undefined,
schema: node.schema || undefined,
tableName,
};
invalidateMetadataCaches(match);
// Connection-node refresh already awaited a strict object-cache invalidation;
// skip the fire-and-forget duplicate so no late async deletion trails it.
if (!options?.skipObjectCacheInvalidation) 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 databaseType = options.node.connectionId ? effectiveDatabaseTypeForConnection(getConfig(options.node.connectionId)) : undefined;
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))),
databaseType,
groupNodeType: options.node.type,
});
const refreshedGroup = grouped.find((group) => group.type === options.node.type);
const children = refreshedGroup?.children ?? [];
return supportsPackageMemberExpansion(databaseType, databaseCompatibilityMode(options.node.connectionId, options.node.database)) ? markPackageNodesExpandable(children) : children;
}
function completionTableDetail(comment: string | null | undefined): string | undefined {
const normalized = comment?.trim();
return normalized ? `→ ${normalized}` : undefined;
}
function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string, catalog?: string): SqlCompletionTable[] {
return tables.map((table) => {
const detail = completionTableDetail(table.comment);
return {
name: table.name,
catalog,
schema,
type: sqlObjectNavigationTypeFromTableType(table.table_type),
...(detail ? { detail } : {}),
...completionStableTableType(table.table_type),
};
});
}
function completionStableTableType(tableType: string | null | undefined): Partial<Pick<SqlCompletionTable, "tableType">> {
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");
const merged = tableChildren.length ? mergeTableTreePageChildren(currentChildren, tableChildren, connectionId, database) : [...currentChildren];
const existing = new Map(merged.map((node) => [treeNodeObjectIdentity(node), node]));
for (const child of nonTableChildren) {
const key = treeNodeObjectIdentity(child);
const existingNode = existing.get(key);
if (existingNode) {
if (child.type === "package" && child.xuguPackageBodyAvailable === true) {
existingNode.xuguPackageBodyAvailable = true;
existingNode.xuguPackageBodyValid = child.xuguPackageBodyValid;
existingNode.valid = existingNode.valid === false || child.valid === false ? false : (existingNode.valid ?? child.valid ?? null);
}
continue;
}
merged.push(child);
existing.set(key, child);
}
const config = parent.connectionId ? getConfig(parent.connectionId) : undefined;
return sortSidebarTreeChildrenByNameKeepingTableVGroups(parent, merged, 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;
pagedSearch?: boolean;
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 userTableNameFilter = activeTableNameFilterForScope({
connectionId: options.node.connectionId,
database: options.node.database,
schema: options.node.schema,
nodeKind: options.node.type,
catalog: options.node.catalog,
});
const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter);
const sourceRevision = tableListSourceRevision(options.node.connectionId);
// Table-scoped search must page through the full ordered result set, just
// like unfiltered loads, instead of silently stopping at the first budget
// window. Global sidebar search keeps the bounded single-shot fetch.
const paginate = options.pagedSearch || !searchFilter;
const fetchLimit = paginate ? options.pageSize + 1 : SIDEBAR_TABLE_SEARCH_RESULT_BUDGET;
const fetchOffset = paginate ? options.offset : undefined;
const tables = await loadCachedMetadataListPage<TableInfo[]>(
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, sourceRevision),
}),
() => listTablesWithOptionalTableNameFilter(options.node.connectionId!, options.node.database!, options.querySchema, searchFilter, fetchLimit, fetchOffset, options.objectTypes, options.node.catalog, tableNameFilter),
{ force: options.force },
);
const hasMore = paginate ? tables.length > options.pageSize : false;
const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables;
if (tableListSourceRevision(options.node.connectionId) === sourceRevision) {
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 userTableNameFilter = activeTableNameFilterForScope({
connectionId: options.node.connectionId,
database: options.node.database,
schema: options.node.schema,
nodeKind: options.node.type,
catalog: options.node.catalog,
});
const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter);
const fetchLimit = searchFilter ? undefined : options.pageSize + 1;
const fetchOffset = searchFilter ? undefined : options.offset;
const objects = await loadCachedMetadataListPage<ObjectInfo[]>(
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,
extra: tableNameFilterMetadataExtra(tableNameFilter),
}),
() =>
tableNameFilter || options.node.catalog
? api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset, options.node.catalog, tableNameFilter)
: 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;
pagedSearch?: boolean;
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 sourceRevision = tableListSourceRevision(options.connectionId);
const paginate = options.pagedSearch || !searchFilter;
const fetchLimit = paginate ? options.pageSize + 1 : SIDEBAR_TABLE_SEARCH_RESULT_BUDGET;
const fetchOffset = paginate ? options.offset : undefined;
const tables = await loadCachedMetadataListPage<TableInfo[]>(
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, sourceRevision),
}),
() => listTablesWithOptionalTableNameFilter(options.connectionId, options.database, options.querySchema, searchFilter, fetchLimit, fetchOffset, undefined, undefined, tableNameFilter),
{ force: options.force },
);
const hasMore = paginate ? tables.length > options.pageSize : false;
const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables;
if (tableListSourceRevision(options.connectionId) === sourceRevision) {
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<ObjectInfo[]>(
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;
const databaseType = effectiveDatabaseTypeForConnection(getConfig(options.connectionId));
let supplementalChildren = buildSimpleObjectTreeNodes({
nodeId: options.nodeId,
connectionId: options.connectionId,
database: options.database,
schema: options.effectiveSchema,
objects: supplementalObjects,
databaseType,
});
if (supportsPackageMemberExpansion(databaseType, databaseCompatibilityMode(options.connectionId, options.database))) {
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));
}
// The metadata tree cache must not persist UI state. isExpanded flags written
// while a schema was expanded would replay that expansion on every cache hit
// (a manual collapse never rewrites the cache), so opening a connection would
// keep auto-expanding a schema the user collapsed. Expansion state is instead
// restored from the live tree (restoreExpandedChildren) within a session.
function stripTreeNodeExpansionState(nodes: TreeNode[]): TreeNode[] {
return nodes.map((node) => ({
...node,
isExpanded: false,
children: node.children ? stripTreeNodeExpansionState(node.children) : node.children,
}));
}
async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string, load: TreeNodeLoadHandle): Promise<PersistedTreeChildrenLoadResult> {
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<unknown>(cacheKey).catch(() => null);
const decoded = decodeSchemaTreeCache<TreeNode[]>(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(stripTreeNodeExpansionState(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[]) {
// 分组是显示层投影,绝不写入元数据缓存——否则重启后会随缓存复现。
const cacheChildren = stripTableVGroupsFromChildren(children);
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(stripTreeNodeExpansionState(stripDatabaseSavedSqlTreeNodes(cacheChildren)))).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-v9" : "objects-grouped-v9");
return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", cacheVersion);
}
function sidebarTableSearchIndexCacheKey(parent: TreeNode): string | null {
const treeCacheKey = sidebarTableSearchTreeCacheKey(parent);
if (!treeCacheKey) return null;
// Catalog-capable databases may expose the same database name in several
// catalogs. Their metadata tree cache keys predate catalog support, so use
// the complete scope identity for the table index without changing those
// shared metadata-cache formats. Catalogless scopes keep the v1 key so
// indexes created before the regex-search manifest remain discoverable.
if (parent.catalog) return schemaCacheKey(parent.connectionId || "", parent.catalog, parent.database || "", parent.schema || "", parent.type, "table-search-index-v2");
return `${treeCacheKey}:table-search-index-v1`;
}
function getSidebarTableSearchIndexMemoryCache(cacheKey: string): TableInfo[] | null | undefined {
if (!sidebarTableSearchIndexCache.has(cacheKey)) return undefined;
const cached = sidebarTableSearchIndexCache.get(cacheKey) ?? null;
sidebarTableSearchIndexCache.delete(cacheKey);
sidebarTableSearchIndexCache.set(cacheKey, cached);
return cached;
}
function setSidebarTableSearchIndexMemoryCache(cacheKey: string, entries: TableInfo[] | null) {
sidebarTableSearchIndexCache.delete(cacheKey);
sidebarTableSearchIndexCache.set(cacheKey, entries);
while (sidebarTableSearchIndexCache.size > SIDEBAR_TABLE_SEARCH_INDEX_CACHE_MAX_ENTRIES) {
const oldest = sidebarTableSearchIndexCache.keys().next().value;
if (oldest === undefined) break;
sidebarTableSearchIndexCache.delete(oldest);
}
}
const sidebarTableSearchIndexManifestCacheKey = "dbx:sidebar-table-search-index-manifest-v1";
function sidebarTableSearchIndexManifestEntry(parent: TreeNode, cacheKey: string): TableSearchIndexManifestEntry | null {
if (!parent.connectionId || !parent.database || !parent.type) return null;
const identity: SidebarRegexScopeIdentity = {
connectionId: parent.connectionId,
database: parent.database,
schema: parent.schema,
catalog: parent.catalog,
nodeType: parent.type,
};
const findPath = (nodes: TreeNode[], targetId: string, path: TreeNode[] = []): TreeNode[] | null => {
for (const node of nodes) {
const next = [...path, node];
if (node.id === targetId) {
// Same-id nodes can exist in other branches; only the node matching
// the full database context is the index parent we registered.
if (nodeMatchesRegexScopeIdentity(node, targetId, identity)) return next;
continue;
}
if (node.children) {
const found = findPath(node.children, targetId, next);
if (found) return found;
}
}
return null;
};
const path = findPath(treeNodes.value, parent.id)?.map((node) => ({
id: node.id,
label: node.label,
type: node.type,
...(node.connectionId ? { connectionId: node.connectionId } : {}),
...(node.database !== undefined ? { database: node.database } : {}),
...(node.catalog !== undefined ? { catalog: node.catalog } : {}),
...(node.schema !== undefined ? { schema: node.schema } : {}),
...(node.linkedServer !== undefined ? { linkedServer: node.linkedServer } : {}),
...(node.linkedCatalog !== undefined ? { linkedCatalog: node.linkedCatalog } : {}),
...(node.linkedSchema !== undefined ? { linkedSchema: node.linkedSchema } : {}),
}));
return {
cacheKey,
parentNodeId: parent.id,
connectionId: parent.connectionId,
database: parent.database,
...(parent.schema ? { schema: parent.schema } : {}),
...(parent.catalog ? { catalog: parent.catalog } : {}),
nodeType: parent.type,
...(path ? { path } : {}),
};
}
async function loadSidebarTableSearchIndexManifest(): Promise<TableSearchIndexManifestEntry[]> {
if (sidebarTableSearchIndexManifest) return sidebarTableSearchIndexManifest;
if (sidebarTableSearchIndexManifestInFlight) return sidebarTableSearchIndexManifestInFlight;
const read = (async () => {
const payload = await api.loadSchemaCache<unknown>(sidebarTableSearchIndexManifestCacheKey).catch(() => null);
sidebarTableSearchIndexManifest = decodeTableSearchIndexManifest(payload);
return sidebarTableSearchIndexManifest;
})();
sidebarTableSearchIndexManifestInFlight = read;
try {
return await read;
} finally {
sidebarTableSearchIndexManifestInFlight = null;
}
}
function sidebarTableSearchIndexConnectionGeneration(connectionId: string): number {
return sidebarTableSearchIndexConnectionGenerations.get(connectionId) ?? 0;
}
function sidebarTableSearchIndexScopeGeneration(cacheKey: string): number {
return sidebarTableSearchIndexScopeGenerations.get(cacheKey) ?? 0;
}
function sidebarTableSearchIndexGenerationIsCurrent(connectionId: string, cacheKey: string, connectionGeneration: number, scopeGeneration: number): boolean {
return connectionGeneration === sidebarTableSearchIndexConnectionGeneration(connectionId) && scopeGeneration === sidebarTableSearchIndexScopeGeneration(cacheKey);
}
async function serializeSidebarTableSearchIndexPersistence(cacheKey: string, operation: () => Promise<void>): Promise<void> {
const previous = sidebarTableSearchIndexPersistenceQueues.get(cacheKey) ?? Promise.resolve();
const pending = previous.catch(() => undefined).then(operation);
sidebarTableSearchIndexPersistenceQueues.set(cacheKey, pending);
try {
await pending;
} finally {
if (sidebarTableSearchIndexPersistenceQueues.get(cacheKey) === pending) {
sidebarTableSearchIndexPersistenceQueues.delete(cacheKey);
}
}
}
type SidebarTableSearchIndexScope = SidebarRegexScopeIdentity & {
parentNodeId: string;
cacheKey: string;
};
function sidebarTableSearchIndexScopeForNode(parent: TreeNode): SidebarTableSearchIndexScope | null {
if (!parent.connectionId || !parent.database) return null;
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
if (!cacheKey) return null;
return {
parentNodeId: parent.id,
connectionId: parent.connectionId,
database: parent.database,
schema: parent.schema,
catalog: parent.catalog,
nodeType: parent.type,
cacheKey,
};
}
function manifestTableSearchScopeMatchesTreeRefresh(scope: TableSearchIndexManifestEntry, node: TreeNode): boolean {
if (!node.connectionId || scope.connectionId !== node.connectionId) return false;
const pathMatch = scope.path?.some((pathNode) => pathNode.id === node.id && pathNode.type === node.type && pathNode.connectionId === node.connectionId);
if (pathMatch) return true;
if (node.type === "connection") return true;
if (node.type === "doris-catalog") return scope.catalog === node.catalog;
if (node.type === "database") return scope.database === node.database && scope.catalog === node.catalog;
if (node.type === "schema") return scope.database === node.database && scope.schema === node.schema && scope.catalog === node.catalog;
if (node.type === "linked-server-schema") return scope.parentNodeId === node.id && scope.nodeType === node.type;
if (node.type === "group-tables") return scope.parentNodeId === node.id && scope.nodeType === node.type;
return false;
}
function collectActiveTableSearchScopes(node: TreeNode): SidebarTableSearchIndexScope[] {
const scopes: SidebarTableSearchIndexScope[] = [];
const visit = (current: TreeNode) => {
if (sidebarTableSearchQueries.value[current.id]?.trim()) {
const scope = sidebarTableSearchIndexScopeForNode(current);
if (scope) scopes.push(scope);
}
for (const child of current.children ?? []) visit(child);
};
visit(node);
return scopes;
}
function publishSidebarTableSearchIndexInvalidation(scopes: SidebarTableSearchIndexScope[]) {
if (scopes.length === 0) return;
sidebarTableSearchIndexInvalidation.value = {
revision: sidebarTableSearchIndexInvalidation.value.revision + 1,
scopes: scopes.map(({ cacheKey: _cacheKey, ...scope }) => scope),
};
}
async function invalidateSidebarTableSearchIndexScopes(scopes: SidebarTableSearchIndexScope[], removeManifestScope: (scope: TableSearchIndexManifestEntry) => boolean): Promise<void> {
const uniqueScopes = [...new Map(scopes.map((scope) => [scope.cacheKey, scope])).values()];
if (uniqueScopes.length === 0) return;
const cacheKeys = new Set(uniqueScopes.map((scope) => scope.cacheKey));
for (const cacheKey of cacheKeys) {
sidebarTableSearchIndexScopeGenerations.set(cacheKey, sidebarTableSearchIndexScopeGeneration(cacheKey) + 1);
sidebarTableSearchIndexCache.delete(cacheKey);
sidebarTableSearchIndexInFlight.delete(cacheKey);
}
const deletePersisted = Promise.all(
[...cacheKeys].map((cacheKey) =>
serializeSidebarTableSearchIndexPersistence(cacheKey, async () => {
await api.deleteSchemaCachePrefix(cacheKey).catch(() => undefined);
}),
),
);
sidebarTableSearchIndexManifestWriteQueue = sidebarTableSearchIndexManifestWriteQueue.then(async () => {
const manifest = await loadSidebarTableSearchIndexManifest();
const nextManifest = manifest.filter((scope) => !cacheKeys.has(scope.cacheKey) && !removeManifestScope(scope));
if (nextManifest.length === manifest.length) return;
sidebarTableSearchIndexManifest = nextManifest;
await api.saveSchemaCache(sidebarTableSearchIndexManifestCacheKey, encodeTableSearchIndexManifest(nextManifest)).catch(() => undefined);
});
await Promise.all([deletePersisted, sidebarTableSearchIndexManifestWriteQueue]);
publishSidebarTableSearchIndexInvalidation(uniqueScopes);
}
async function invalidateSidebarTableSearchIndexesForConnection(connectionId: string): Promise<void> {
sidebarTableSearchIndexConnectionGenerations.set(connectionId, sidebarTableSearchIndexConnectionGeneration(connectionId) + 1);
const rawPrefix = `${connectionId}:`;
const encodedPrefix = `${schemaCacheKey(connectionId)}:`;
const matchesConnectionCacheKey = (cacheKey: string) => cacheKey.startsWith(rawPrefix) || cacheKey.startsWith(encodedPrefix);
for (const cacheKey of sidebarTableSearchIndexCache.keys()) {
if (matchesConnectionCacheKey(cacheKey)) sidebarTableSearchIndexCache.delete(cacheKey);
}
for (const cacheKey of sidebarTableSearchIndexInFlight.keys()) {
if (matchesConnectionCacheKey(cacheKey)) sidebarTableSearchIndexInFlight.delete(cacheKey);
}
const manifest = await loadSidebarTableSearchIndexManifest();
const manifestScopes = manifest
.filter((scope) => scope.connectionId === connectionId)
.map((scope) => ({
parentNodeId: scope.parentNodeId,
connectionId: scope.connectionId,
database: scope.database,
schema: scope.schema,
catalog: scope.catalog,
nodeType: scope.nodeType,
cacheKey: scope.cacheKey,
}));
const connectionNode = findConnectionNode(connectionId);
const activeScopes = connectionNode ? collectActiveTableSearchScopes(connectionNode) : [];
await invalidateSidebarTableSearchIndexScopes([...manifestScopes, ...activeScopes], (scope) => scope.connectionId === connectionId);
}
async function invalidateSidebarTableSearchIndexesForTreeRefresh(node: TreeNode): Promise<void> {
const manifest = await loadSidebarTableSearchIndexManifest();
const manifestScopes = manifest
.filter((scope) => manifestTableSearchScopeMatchesTreeRefresh(scope, node))
.map((scope) => ({
parentNodeId: scope.parentNodeId,
connectionId: scope.connectionId,
database: scope.database,
schema: scope.schema,
catalog: scope.catalog,
nodeType: scope.nodeType,
cacheKey: scope.cacheKey,
}));
const activeScopes = collectActiveTableSearchScopes(node);
await invalidateSidebarTableSearchIndexScopes([...manifestScopes, ...activeScopes], (scope) => manifestTableSearchScopeMatchesTreeRefresh(scope, node));
}
async function registerSidebarTableSearchIndexScope(parent: TreeNode, cacheKey: string): Promise<void> {
const entry = sidebarTableSearchIndexManifestEntry(parent, cacheKey);
if (!entry) return;
sidebarTableSearchIndexManifestWriteQueue = sidebarTableSearchIndexManifestWriteQueue.then(async () => {
const manifest = await loadSidebarTableSearchIndexManifest();
const matchesEntryScope = (item: TableSearchIndexManifestEntry) => item.parentNodeId === entry.parentNodeId && item.connectionId === entry.connectionId && item.database === entry.database && item.schema === entry.schema && item.catalog === entry.catalog && item.nodeType === entry.nodeType;
const existing = manifest.find((item) => matchesEntryScope(item));
if (existing?.cacheKey === entry.cacheKey) return;
// Re-registering a scope after its cache-key format changes replaces the
// old manifest entry, so stale and refreshed indexes are never merged.
sidebarTableSearchIndexManifest = [...manifest.filter((item) => !matchesEntryScope(item)), entry];
await api.saveSchemaCache(sidebarTableSearchIndexManifestCacheKey, encodeTableSearchIndexManifest(sidebarTableSearchIndexManifest)).catch(() => undefined);
});
await sidebarTableSearchIndexManifestWriteQueue;
}
async function readSidebarTableSearchIndexCache(cacheKey: string, connectionId: string): Promise<TableInfo[] | null> {
const cached = getSidebarTableSearchIndexMemoryCache(cacheKey);
if (cached !== undefined) return cached;
const pending = sidebarTableSearchIndexInFlight.get(cacheKey);
if (pending) return pending;
const connectionGeneration = sidebarTableSearchIndexConnectionGeneration(connectionId);
const scopeGeneration = sidebarTableSearchIndexScopeGeneration(cacheKey);
const read = (async () => {
const decoded = decodeSchemaTreeCache<TreeNode[]>(await api.loadSchemaCache<unknown>(cacheKey).catch(() => null));
const index = decoded?.tableSearchIndex;
const entries = index ? index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType, ...(entry.comment !== undefined ? { comment: entry.comment } : {}) })) : null;
if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return null;
setSidebarTableSearchIndexMemoryCache(cacheKey, entries);
return entries;
})();
sidebarTableSearchIndexInFlight.set(cacheKey, read);
try {
return await read;
} finally {
if (sidebarTableSearchIndexInFlight.get(cacheKey) === read) sidebarTableSearchIndexInFlight.delete(cacheKey);
}
}
function findSidebarTreeNodeByIdentity(parentNodeId: string, identity: SidebarRegexScopeIdentity): TreeNode | null {
const path = findNodePathByIdentity(treeNodes.value, parentNodeId, identity);
return path?.[path.length - 1] ?? null;
}
async function loadSidebarTableSearchIndex(parentNodeId: string, identity?: SidebarRegexScopeIdentity): Promise<TableInfo[] | null> {
// The identity disambiguates same-id nodes (e.g. database "a:b" vs schema
// "b" under database "a") so the correct cache key is read and registered.
const parent = identity ? findSidebarTreeNodeByIdentity(parentNodeId, identity) : findNode(treeNodes.value, parentNodeId);
if (!parent) return null;
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
if (!cacheKey) return null;
const entries = await readSidebarTableSearchIndexCache(cacheKey, parent.connectionId || "");
if (entries) await registerSidebarTableSearchIndexScope(parent, cacheKey);
return entries;
}
async function loadSidebarTableSearchIndexScopes(): Promise<Array<{ scope: TableSearchIndexManifestEntry; entries: TableInfo[] }>> {
const manifest = await loadSidebarTableSearchIndexManifest();
const scopes: Array<{ scope: TableSearchIndexManifestEntry; entries: TableInfo[] }> = [];
for (const scope of manifest) {
const entries = await readSidebarTableSearchIndexCache(scope.cacheKey, scope.connectionId);
if (entries) scopes.push({ scope, entries });
}
return scopes;
}
async function refreshSidebarTableSearchIndex(parentNodeId: string, identity?: SidebarRegexScopeIdentity): Promise<TableInfo[]> {
const parent = identity ? findSidebarTreeNodeByIdentity(parentNodeId, identity) : findNode(treeNodes.value, parentNodeId);
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return [];
const connectionId = parent.connectionId;
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
if (!cacheKey) return [];
const connectionGeneration = sidebarTableSearchIndexConnectionGeneration(connectionId);
const scopeGeneration = sidebarTableSearchIndexScopeGeneration(cacheKey);
await ensureConnected(connectionId);
const config = getConfig(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) {
if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return [];
const page = await listTablesWithOptionalTableNameFilter(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, ...(entry.comment !== undefined ? { comment: entry.comment } : {}) })),
};
if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return [];
let persisted = false;
await serializeSidebarTableSearchIndexPersistence(cacheKey, async () => {
if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return;
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache<TreeNode[]>([], Date.now(), tableSearchIndex));
if (!sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) {
await api.deleteSchemaCachePrefix(cacheKey).catch(() => undefined);
return;
}
persisted = true;
});
if (!persisted || !sidebarTableSearchIndexGenerationIsCurrent(connectionId, cacheKey, connectionGeneration, scopeGeneration)) return [];
setSidebarTableSearchIndexMemoryCache(cacheKey, deduped);
await registerSidebarTableSearchIndexScope(parent, cacheKey);
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");
}
// v3 includes per-database compatibilityMode used by openGauss A-mode capabilities.
return schemaCacheKey(connectionId, "databases-v3");
}
async function hydrateTreeNodeFromCache(node: TreeNode | null, cacheKey: string | null): Promise<boolean> {
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<boolean> {
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);
if (isSidebarSearchQueryChanged(options)) return true;
if (options?.allowGlobalSearchMismatch) return false;
return (sidebarSearchQuery.value || "") !== searchFilter;
}
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;
}
// Remote sidebar search temporarily replaces an object group's ordinary
// children with a filtered projection. When search expanded a previously
// collapsed group, discard that projection on clear instead of letting the
// loaded marker make the next user expansion reuse incomplete children.
function discardFilteredTreeNodeChildren(nodeId: string): boolean {
if (!filteredObjectGroupChildrenIds.has(nodeId)) return false;
const node = findNode(treeNodes.value, nodeId);
if (!node) {
filteredObjectGroupChildrenIds.delete(nodeId);
return false;
}
clearLoadedChildrenCache(nodeId, { deletePersisted: false });
treeNodeLoads.invalidatePrefix(nodeId);
node.children = [];
node.objectCount = undefined;
node.isLoading = false;
return true;
}
// Remote sidebar search is a temporary projection over an object group. When the
// query is cleared, put the captured pre-search children back — including the
// pages loaded through "load more" — instead of refetching page one. Returns
// false when nothing was captured (the group was never loaded before the
// search), so the caller can fall back to a normal load.
function restoreFilteredObjectGroupChildren(node: TreeNode): boolean {
const snapshot = filteredObjectGroupChildrenSnapshots.get(node.id);
const liveNode = treeNodeInSidebarTree(node);
if (!snapshot || !liveNode) {
if (!liveNode) forgetFilteredObjectGroupChildren(node.id);
return false;
}
setChildren(liveNode, snapshot.children);
liveNode.objectCount = snapshot.objectCount;
liveNode.isExpanded = true;
forgetFilteredObjectGroupChildren(liveNode.id);
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);
}
}
for (const id of filteredObjectGroupChildrenIds) {
if (id === prefix || id.startsWith(`${prefix}:`)) {
filteredObjectGroupChildrenIds.delete(id);
}
}
for (const id of filteredObjectGroupChildrenSnapshots.keys()) {
if (id === prefix || id.startsWith(`${prefix}:`)) {
filteredObjectGroupChildrenSnapshots.delete(id);
}
}
invalidateMetadataCachesByTreePrefix(prefix);
if (options?.deletePersisted === false) return;
deletePersistedSchemaCachePrefix(prefix);
}
// Persisted tree caches are keyed by colon-joined node ids, so both the raw
// id form and the fully-encoded legacy form must be invalidated. Failures are
// swallowed on purpose: a dropped delete only leaves a stale cache entry that
// the next save supersedes.
function deletePersistedSchemaCachePrefix(prefix: string) {
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);
}
}
// Discarding sibling shells used to cost one prefix DELETE per node — on a
// large schema a list replacement or a remote search fired a request storm
// (issue #9779). Sibling ids all live under the parent's id prefix, so one
// ancestor delete covers the whole batch; ids that do not follow the parent's
// id path (encoded MQ/Nacos-style ids) keep per-node deletes, deduplicated to
// the minimal set so nested or duplicate prefixes share a request. The widened
// ancestor invalidation only drops caches that the next expand refetches — the
// in-memory markers for the same discarded shells were already cleared above —
// so nothing stale can survive, at worst one extra metadata fetch.
function deletePersistedTreeCachesForDiscardedDescendants(ancestorId: string, discardedIds: string[]) {
if (discardedIds.length === 0) return;
const ancestorPrefix = `${ancestorId}:`;
const prefixes = new Set<string>();
let coveredByAncestor = false;
for (const id of discardedIds) {
if (id.startsWith(ancestorPrefix)) coveredByAncestor = true;
else prefixes.add(id);
}
if (coveredByAncestor) prefixes.add(ancestorId);
for (const prefix of prefixes) {
// A prefix already covered by another prefix in the batch needs no request.
let covered = false;
for (const other of prefixes) {
if (other !== prefix && prefix.startsWith(`${other}:`)) {
covered = true;
break;
}
}
if (!covered) deletePersistedSchemaCachePrefix(prefix);
}
}
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<string> {
const dragged = findPinnedTreeNodeLocation(treeNodes.value, draggedKey);
if (!dragged || !isTreeNodePinned(dragged.node) || isFixedPriorityTreeNode(dragged.node)) return new Set();
const targets = new Set<string>();
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<string>();
});
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);
if (normalized.save_password === false) normalized.password = "";
const isNewConnection = !connections.value.some((c) => c.id === normalized.id);
// 新建同名同类型连接时回填「记住的数据库」,让删除连接后重建的流程少一步手选。
if (isNewConnection && !normalized.database?.trim()) {
const remembered = settingsStore.rememberedDatabaseForConnection(normalized.name, normalized.db_type);
if (remembered) normalized.database = remembered;
}
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();
// 删除连接时保留下来的 SQL 页签按连接名重新绑定到新连接上(含数据库回填)。
if (isNewConnection) {
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
if (queryStore.rebindDetachedTabs(normalized.name, normalized.id, normalized.database) > 0) {
await queryStore.flushPendingPersist().catch(() => undefined);
}
}
}
function copyConnectionsToTreeClipboard(connectionIds: Iterable<string>): number {
const seen = new Set<string>();
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<number> {
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 completionCacheRevision(connectionId?: string, database?: string): number {
if (!connectionId) return 0;
const connectionRevision = completionCacheRevisions.value[connectionId] ?? 0;
if (database == null) return connectionRevision;
return connectionRevision + (completionCacheRevisions.value[`${connectionId}:${database}`] ?? 0);
}
function bumpCompletionCacheRevision(connectionId: string, database?: string) {
const key = database == null ? connectionId : `${connectionId}:${database}`;
completionCacheRevisions.value[key] = (completionCacheRevisions.value[key] ?? 0) + 1;
}
function invalidateCompletionCache(connectionId: string, database?: string) {
bumpCompletionCacheRevision(connectionId, database);
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(elasticsearchCompletionFieldsCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionFieldsCache.value[key];
}
for (const key of Object.keys(redisCompletionKeysCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key];
}
if (database == null) {
delete redisCommandDocsCache.value[connectionId];
redisCommandDocsCacheGeneration.set(connectionId, (redisCommandDocsCacheGeneration.get(connectionId) ?? 0) + 1);
}
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 completionColumnPrefixIndex.keys()) {
if (key.startsWith(cachePrefix)) completionColumnPrefixIndex.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<string>) {
const connectionIds = [...new Set(ids)].filter((id) => connections.value.some((c) => c.id === id));
if (!connectionIds.length) return;
const removedIds = new Set(connectionIds);
const oneTimeIds = connectionIds.filter((id) => getConfig(id)?.one_time === true);
// 连接配置在 applyConnectionRemoval 后就读不到了,先抓取快照供删除策略使用。
const removedConfigs = connectionIds.map((id) => getConfig(id)).filter((config): config is ConnectionConfig => !!config);
const nextConnections = connections.value.filter((c) => !removedIds.has(c.id));
let nextLayout = sidebarLayout.value;
for (const id of removedIds) nextLayout = removeConnectionFromSidebarLayout(nextLayout, id);
await persistConnectionDeletion(nextConnections, nextLayout);
applyConnectionRemoval(removedIds, nextConnections, nextLayout);
purgeTableVGroupsForConnections(removedIds);
// 删除已经落盘完成;页签处理失败只告警,不能让已成功的删除以异常收场。
try {
await applyDeletedConnectionTabHandling(removedConfigs);
} catch (error) {
console.warn("[DBX][connection:delete:tab-handling-failed]", { connectionIds: [...removedIds], error });
}
await cleanupRemovedOneTimeConnections(oneTimeIds);
}
/** 删除连接后同步清理其名下的表分组布局(本地内存 + dbx.db 行)。 */
function purgeTableVGroupsForConnections(removedIds: ReadonlySet<string>) {
const nextLayouts = { ...tableVGroupLayouts.value };
let removed = false;
for (const id of removedIds) {
const prefix = `${id}\u0000`;
for (const key of Object.keys(nextLayouts)) {
if (key.startsWith(prefix)) {
delete nextLayouts[key];
removed = true;
}
}
}
if (removed) tableVGroupLayouts.value = nextLayouts;
for (const id of removedIds) void api.deleteTableVGroupsForConnection(id).catch(() => {});
}
function applyConnectionRemoval(removedIds: ReadonlySet<string>, nextConnections: ConnectionConfig[], nextLayout: SidebarLayout) {
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);
clearEtcdAccessCapabilities(id);
connectedIds.value.delete(id);
clearSidebarStorageCaches(id);
clearPrimaryVisibleObjectNames(id);
clearConnectionIdentifierQuote(id);
clearConnectionHealthCheck(id);
}
sidebarLayout.value = nextLayout;
rebuildTreeNodes();
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);
if (config.save_password === false) config.password = "";
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;
clearEtcdAccessCapabilities(config.id);
clearPrimaryVisibleObjectNames(config.id);
connectedIds.value.delete(config.id);
clearSidebarStorageCaches(config.id);
clearConnectionIdentifierQuote(config.id);
clearConnectionHealthCheck(config.id);
invalidateCompletionCache(config.id);
void invalidateObjectDdlCache({ connectionId: config.id });
await invalidateSidebarTableSearchIndexesForConnection(config.id);
clearLoadedChildrenCache(config.id);
const node = findConnectionNode(config.id);
if (node?.isExpanded) {
await reloadConnectionDatabaseChildren(config.id);
}
}
async function updateRedisKeyGrouping(connectionId: string, grouping: import("@/lib/redis/redisKeyGrouping").RedisKeyGrouping) {
const { validateRedisKeyGrouping } = await import("@/lib/redis/redisKeyGrouping");
const validated = validateRedisKeyGrouping(grouping);
const index = connections.value.findIndex((connection) => connection.id === connectionId);
if (index < 0) throw new Error("Connection not found");
const next = [...connections.value];
next[index] = { ...next[index]!, redis_key_grouping: validated };
await persistConnections(next);
// Presentation-only preferences must not invalidate a live connection.
connections.value = next;
}
async function renameConnection(connectionId: string, name: string): Promise<boolean> {
const trimmed = name.trim();
if (!trimmed) return false;
const index = connections.value.findIndex((connection) => connection.id === connectionId);
if (index < 0 || connections.value[index].name === trimmed) return false;
const nextConnections = [...connections.value];
nextConnections[index] = { ...nextConnections[index], name: trimmed };
await persistConnections(nextConnections);
connections.value = nextConnections;
rebuildTreeNodes();
return true;
}
async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise<void> {
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;
const tableListSourceChanged = mysqlTableListSourceChanged(current, normalized);
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;
if (tableListSourceChanged) {
// ShardingSphere switches table enumeration from information_schema to SHOW FULL TABLES.
// Discard physical-table metadata that may have loaded before background version detection completed.
bumpTableListSourceRevision(connectionId);
treeNodeLoads.cancelPrefix(connectionId);
invalidateCompletionCache(connectionId);
void invalidateSidebarTableSearchIndexesForConnection(connectionId);
const connectionNode = findConnectionNode(connectionId);
if (connectionNode?.isExpanded && connectedIds.value.has(connectionId)) {
void refreshTreeNode(connectionNode).catch((error) => {
console.debug("[DBX][connection-info:table-metadata-refresh-failed]", { connectionId, error });
});
} else {
clearLoadedChildrenCache(connectionId);
}
}
// Other database info is reactive connection metadata, not tree structure. Keep
// navigator node identities stable so an in-flight first expansion can apply
// its loaded children after this background refresh completes.
}
async function refreshConnectedDatabaseInfo(connectionId: string, config: ConnectionConfig): Promise<void> {
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);
notifyComponentUpdatesChanged();
}
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 || (config.db_type === "xugu" && isXuguSyntheticScope(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);
}
// 显式勾选 + 通配符模式一并保存(#7164):模式对之后新建的库持续生效
async function setVisibleDatabaseFilter(connectionId: string, databaseNames: string[], patterns: string[]) {
const config = getConfig(connectionId);
if (!config) return;
const normalizedPatterns = patterns.map((pattern) => pattern.trim()).filter((pattern) => pattern !== "");
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
if (idx < 0) return;
const nextConnections = [...connections.value];
nextConnections[idx] = {
...nextConnections[idx],
visible_databases: normalizeVisibleDatabaseSelection(databaseNames, databaseNames),
visible_database_patterns: normalizedPatterns.length > 0 ? normalizedPatterns : undefined,
};
await persistConnections(nextConnections);
connections.value = nextConnections;
invalidateCompletionCache(connectionId);
rebuildTreeNodes();
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) && !visibleDatabasePatternsAreEnabled(config.visible_database_patterns))) return;
if (visibleDatabasePatternsAreEnabled(config.visible_database_patterns)) {
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
if (idx >= 0) {
const nextConnections = [...connections.value];
nextConnections[idx] = { ...nextConnections[idx], visible_databases: undefined, visible_database_patterns: undefined };
await persistConnections(nextConnections);
connections.value = nextConnections;
invalidateCompletionCache(connectionId);
rebuildTreeNodes();
await reloadConnectionDatabaseChildren(connectionId);
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<string, string[]> | 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 === "dynamodb") {
await loadDynamoDbTables(connectionId);
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch" || config.db_type === "solr") {
// Reload: list indices/cores.
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 if (config.db_type === "plugin") {
return;
} else {
await loadDatabases(connectionId, { force: true });
}
}
/**
* Query whether this connection already has a transient password stored in the
* backend session-credential store (a `save_password === false` connection that
* was connected once this run). Used to skip the interactive password prompt on
* later connects. Only returns a boolean — the password itself never leaves the
* backend process memory.
*/
async function hasSessionCredential(connectionId: string): Promise<boolean> {
try {
return await api.sessionCredentialStatus(connectionId);
} catch {
// Status query failure is treated as "no credential": falling back to the
// interactive prompt is the safe, well-known path.
return false;
}
}
/**
* Prompt for a transient password when saving is disabled, or when a server
* confirms that a metadata-only synced connection sent no password. The
* password is used for the immediate `connectDb` call and is persisted only
* after a successful connection when the user selects "remember password".
*/
async function ensureConnectionPassword(config: ConnectionConfig, force = false): Promise<{ config: ConnectionConfig; rememberPassword: boolean }> {
if (!force && (config.save_password !== false || config.password)) return { config, rememberPassword: false };
const { useConnectionPasswordPromptStore } = await import("@/stores/connectionPasswordPromptStore");
const result = await useConnectionPasswordPromptStore().requestPassword({
connectionId: config.id,
connectionName: config.name,
});
if (!result) throw new Error(CONNECTION_PASSWORD_REQUIRED_MESSAGE);
return { config: { ...config, password: result.password }, rememberPassword: result.rememberPassword };
}
/**
* Manifest re-check for plugin connections that the save_password heuristic
* already flagged. The prompt can only fill the `password`-bound field, so
* when the manifest does not require it for the connection's external_config
* (SSH private-key / agent auth), connecting proceeds without prompting
* instead of failing with "Password is required". Non-plugin configs always
* prompt (the heuristic decided already). Callers must evaluate
* `connectionNeedsPasswordPrompt` synchronously first so the common
* no-prompt path keeps its microtask cadence.
*/
async function pluginConnectionPasswordPromptNeeded(config: ConnectionConfig): Promise<boolean> {
if (config.db_type !== "plugin") return true;
if (!config.plugin_id || !config.plugin_connection_provider) return false;
try {
const registry = createFrontendPluginRegistry(await api.listPlugins(), currentLocale());
const provider = registry.listConnectionProviders().find((entry) => entry.plugin.manifest.id === config.plugin_id && entry.contribution.id === config.plugin_connection_provider);
if (!provider) return false;
return pluginConnectionNeedsPasswordPrompt(provider.contribution.fields, config.external_config);
} catch {
// Manifest unavailable: keep the pre-manifest behavior (prompt) instead of
// silently connecting a connection that may genuinely need a password.
return true;
}
}
async function persistRememberedConnectionPassword(config: ConnectionConfig, rememberPassword: boolean, expectedConfigFingerprint: string): Promise<void> {
if (!rememberPassword) return;
const index = connections.value.findIndex((connection) => connection.id === config.id);
if (index < 0) return;
if (connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) return;
const nextConnections = [...connections.value];
nextConnections[index] = { ...nextConnections[index], password: config.password, save_password: true };
await persistConnections(nextConnections);
if (connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) {
await persistConnections();
return;
}
connections.value = nextConnections;
rebuildTreeNodes();
}
async function connectDbWithPasswordRetry(config: ConnectionConfig, localAttempt: number): Promise<{ config: ConnectionConfig; id: string; rememberPassword: boolean }> {
try {
const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config);
return { config, id, rememberPassword: false };
} catch (error) {
const message = connectionErrorMessage(error);
if (!isMysqlMissingPasswordFailure(config, message) && !isSqliteMissingEncryptionPasswordFailure(config, message)) throw error;
const prompted = await ensureConnectionPassword(config, true);
config = prompted.config;
ensureLocalConnectionAttemptActive(config.id, localAttempt);
const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config);
return { config, id, rememberPassword: prompted.rememberPassword };
}
}
async function connect(config: ConnectionConfig) {
config = normalizeConnection(config);
const expectedConfigFingerprint = connectionConfigFingerprint(getConfig(config.id) ?? config);
if (getBlockingDisconnectInFlight(config.id)) await waitForBlockingDisconnectInFlight(config.id);
const localAttempt = beginLocalConnectionAttempt(config.id);
try {
let rememberPassword = false;
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(config.id))) {
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
}
await beforeConnectHandler?.(config);
if (config.db_type === "sqlserver") {
await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
}
ensureLocalConnectionAttemptActive(config.id, localAttempt);
const connection = await connectDbWithPasswordRetry(config, localAttempt);
config = connection.config;
rememberPassword ||= connection.rememberPassword;
const id = connection.id;
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
await syncMongoLegacyDriverFallback(id, config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
activeConnectionId.value = id;
connectedIds.value.add(id);
if (config.db_type !== "plugin") {
void refreshConnectedDatabaseInfo(id, { ...config, id });
await refreshConnectionIdentifierQuote(id, { ...config, id });
// Compatibility modes warm asynchronously; the QueryEditor watcher and the
// backend's own A-mode/EXISTS validation make a cold map safe.
void refreshConnectionDatabaseModes(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.searchAliases = connectionSidebarSearchAliases(config);
existing.connectionId = id;
existing.comment = config.note || null;
existing.children = existing.children || [];
} else {
treeNodes.value.push({
id,
label: config.name,
type: "connection",
searchAliases: connectionSidebarSearchAliases(config),
connectionId: id,
isExpanded: false,
children: [],
comment: config.note || null,
});
}
try {
await persistRememberedConnectionPassword(config, rememberPassword, expectedConfigFingerprint);
} catch (error) {
setConnectionError(id, i18n.global.t("connection.rememberPasswordSaveFailed", { message: connectionErrorMessage(error) }));
}
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<boolean> {
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);
clearEtcdAccessCapabilities(connectionId);
clearSidebarStorageCaches(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionIdentifierQuote(connectionId);
clearConnectionHealthCheck(connectionId);
clearConnectionPrewarmState(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
invalidateCompletionCache(connectionId);
cancelObjectDdlLoadsForConnection(connectionId);
cancelObjectMetadataLoadsForConnection(connectionId);
await disconnectRequest;
return true;
}
/**
* Marks a connection offline in the sidebar without the full teardown that
* `disconnect` performs (no tab closing, no pool teardown). Used when a
* plugin reports its live session died (e.g. `ssh/session/state
* disconnected`) so the tree reflects the real transport state.
*/
function markConnectionOffline(connectionId: string) {
connectedIds.value.delete(connectionId);
const node = findConnectionNode(connectionId);
if (node) {
node.isLoading = false;
}
clearConnectionHealthCheck(connectionId);
clearConnectionPrewarmState(connectionId);
}
/**
* 断开连接。
*
* `options.skipTabHandling` 用于「删除连接」流程:删除时页签已按
* `deleteConnectionTabHandlingMode` 处理过,这里只做会话清理,不能再套用
* `disconnectTabHandlingMode`,否则会把刚保留下来的 SQL 页签又关掉。
*/
async function disconnect(connectionId: string, options: { skipTabHandling?: boolean } = {}) {
const stateRevision = bumpConnectionStateRevision(connectionId);
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
if (hasSqlServerActivityTraceForConnection(connectionId)) await disposeSqlServerActivityTracesForConnection(connectionId);
const disconnectRequest = startDisconnectRequest(connectionId);
cancelLocalConnectionAttempt(connectionId);
connectedIds.value.delete(connectionId);
clearEtcdAccessCapabilities(connectionId);
clearSidebarStorageCaches(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionIdentifierQuote(connectionId);
forgetSuccessfulLocalConnectionAttempt(connectionId);
clearConnectionHealthCheck(connectionId);
clearConnectionPrewarmState(connectionId);
const node = findConnectionNode(connectionId);
if (node) {
node.isLoading = false;
node.isExpanded = false;
node.children = [];
}
clearConnectionRootMetadataLoad(connectionId);
cancelObjectDdlLoadsForConnection(connectionId);
cancelObjectMetadataLoadsForConnection(connectionId);
clearMetadataRuntimeCacheForConnection(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 });
await invalidateSidebarTableSearchIndexesForConnection(connectionId);
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
// 断开连接是明确的元数据新鲜度边界:数据标签页保留展示/编辑状态,但
// 其 tableMeta 不得再被视为 warm cache(issue #6623——reconnect 后重开
// 同表必须重新拉取结构)。completion/object-browser 缓存上面已清;
// 这里再走统一 helper,bump 连接代次并清 data-tab freshness。
invalidateConnectionMetadataLifetime(connectionId);
if (!options.skipTabHandling) {
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;
}
}
// Tab handling is synchronous in memory, but persistence is debounced. Flush
// the scoped tab state before disconnect returns so a quick reconnect/restart
// cannot restore tabs that the selected policy already removed.
await queryStore.flushPendingPersist().catch(() => undefined);
await disconnectRequest;
if (isCurrentConnectionStateRevision(connectionId, stateRevision)) {
clearConnectionError(connectionId);
}
if (shouldRemoveOneTimeConnection && isCurrentConnectionStateRevision(connectionId, stateRevision)) {
await removeConnection(connectionId);
}
}
/**
* "断开并忘记本次密码":关闭连接池(保留其它已保存连接的凭据),并清除该
* `save_password === false` 连接本次运行期的会话密码,使下一次连接必须重新输入。
* 若该连接本无会话凭据,后端会返回错误并在此抛出(不静默吞掉),避免误报成功。
*/
async function disconnectAndForgetConnectionPassword(connectionId: string) {
await disconnect(connectionId);
await api.forgetSessionCredential(connectionId);
}
async function closeDatabaseConnection(connectionId: string, database: string) {
if (hasSqlServerActivityTraceForConnection(connectionId, database)) await disposeSqlServerActivityTracesForConnection(connectionId, database);
cancelObjectDdlLoadsForDatabase(connectionId, database);
cancelObjectMetadataLoadsForDatabase(connectionId, database);
await api.closeDatabaseConnection(connectionId, database);
clearMetadataRuntimeCacheForDatabase(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;
}
// Keep database-level disconnect consistent with connection-level cleanup:
// the next restore must observe the post-policy tab set, not the debounced
// snapshot from before this database was closed.
await queryStore.flushPendingPersist().catch(() => undefined);
const node = findDatabaseTreeNode(treeNodes.value, connectionId, database);
if (node) {
node.isExpanded = false;
node.children = [];
clearLoadedChildrenCache(node.id);
}
invalidateCompletionCache(connectionId, database);
clearSidebarStorageCaches(connectionId, database);
invalidateObjectBrowserRowsCache({ connectionId, database });
// 数据库级生命周期边界:与连接级断开一致,数据标签页的元数据 freshness
// 戳必须作废,重开/刷新时重新拉取结构(issue #6623)。bump 数据库级代次
// 只影响该库的在途回写与 reload 判定,不影响本连接其它库。
invalidateConnectionMetadataLifetime(connectionId, database);
}
async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean } = {}) {
if (!options.forceReconnect && connectedIds.value.has(connectionId)) {
// Pure navigation can safely trust the existing connected state. Its
// destination will perform the real API request, while blocking here on
// a health probe makes an otherwise local tab switch take up to 5s.
if (options.verifyHealth === false) return;
if (hasRecentConnectionHealthCheck(connectionId)) return;
const stateRevision = connectionStateRevision(connectionId);
// Optimistic: verify backend pool is actually healthy
try {
await withConnectionHealthTimeout(connectionId, api.checkConnectionHealth(connectionId));
if (!isCurrentConnectionStateRevision(connectionId, stateRevision)) throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE);
markConnectionHealthChecked(connectionId);
return;
} catch {
// A late probe must not undo an explicit disconnect or a newer reconnect.
if (!isCurrentConnectionStateRevision(connectionId, stateRevision)) throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE);
// Backend pool is dead — remove from connectedIds and reconnect.
// 死池重连同样跨越了连接生命周期:shared 表元数据缓存与数据标签页的
// 元数据 freshness 都必须作废,否则自动重连后仍可能复用断链前的旧
// 字段结构(issue #6623)。
cancelObjectDdlLoadsForConnection(connectionId);
cancelObjectMetadataLoadsForConnection(connectionId);
clearMetadataRuntimeCacheForConnection(connectionId);
connectedIds.value.delete(connectionId);
clearEtcdAccessCapabilities(connectionId);
clearPrimaryVisibleObjectNames(connectionId);
clearConnectionHealthCheck(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
invalidateConnectionMetadataLifetime(connectionId);
}
}
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;
}
const expectedConfigFingerprint = connectionConfigFingerprint(config);
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 () => {
let rememberPassword = false;
// Fast-path the common case (password saved or no password needed) so the
// in-flight dedup above keeps its exact microtask cadence; only await the
// interactive prompt when the connection actually needs a typed password.
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) {
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
}
await beforeConnectHandler?.(config);
if (config.db_type === "sqlserver") {
await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
}
ensureLocalConnectionAttemptActive(connectionId, localAttempt);
const connection = await connectDbWithPasswordRetry(config, localAttempt);
config = connection.config;
rememberPassword ||= connection.rememberPassword;
const id = connection.id;
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
await syncMongoLegacyDriverFallback(connectionId, config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
connectedIds.value.add(connectionId);
if (config.db_type !== "plugin") {
void refreshConnectedDatabaseInfo(connectionId, config);
await refreshConnectionIdentifierQuote(connectionId, config);
void refreshConnectionDatabaseModes(connectionId, config);
}
markSuccessfulLocalConnectionAttempt(connectionId, localAttempt);
markConnectionHealthChecked(connectionId);
clearConnectionError(connectionId);
try {
await persistRememberedConnectionPassword(config, rememberPassword, expectedConfigFingerprint);
} catch (error) {
setConnectionError(connectionId, i18n.global.t("connection.rememberPasswordSaveFailed", { message: connectionErrorMessage(error) }));
}
})();
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;
}
// 后台搜索触发的重连失败只留一行提示,避免把完整驱动错误(如旧版 SQL Server 的 TLS 提示)
// 记到连接节点上,也避免搜索每轮都重复报同一个错。
if (isSidebarSearchLoad(connectionId) && !isSupersededConnectionAttempt(e)) recordSidebarSearchConnectionFailure(connectionId, e);
else recordConnectionError(connectionId, e);
clearConnectionNodeLoading(connectionId);
throw e;
} finally {
if (connectInFlight.get(connectionId) === connectPromise) {
connectInFlight.delete(connectionId);
}
finishLocalConnectionAttempt(connectionId, localAttempt);
}
}
/**
* Re-push an already-open plugin connection's config (credentials included)
* to its sidecar through the same connection/connect path used when opening
* from the sidebar. A plugin iframe reload can leave the sidecar's in-memory
* connection registry empty while the host still considers the connection
* open, and ensureConnected()'s health fast-path would not heal that. Silent
* no-op when the connection is not open, is not plugin-backed, or its
* credentials are no longer available (save_password=false without a live
* session credential): the plugin then keeps its existing "reopen from the
* sidebar" guidance instead of triggering an interactive prompt from a
* background re-init.
*/
async function repushPluginConnection(connectionId: string): Promise<void> {
const config = getConfig(connectionId);
if (!config || config.db_type !== "plugin" || !connectedIds.value.has(connectionId)) return;
// A successful connect/health probe within the TTL means the sidebar open
// (or a fresh restore connect) pushed the config moments ago and the
// sidecar registry cannot plausibly be empty yet — skipping here keeps the
// first open from paying a redundant disconnect+connect cycle on the
// plugin's first `ready`. The 2s in-memory TTL dies with the frontend, so
// every realistic reload path still re-pushes.
if (hasRecentConnectionHealthCheck(connectionId)) return;
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) return;
await ensureConnected(connectionId, { activate: false, forceReconnect: true });
}
/**
* Explicit, user-triggered reconnect of a plugin connection (the plugin's own
* "reconnect" button). Unlike repushPluginConnection — the silent background
* heal — this runs the full connect flow and MAY show the interactive
* password prompt, which is appropriate for a deliberate user action. Editing
* a connection drops it from connectedIds without notifying plugins, so the
* sidecar's config goes stale; this is the plugin's way to request a fresh
* connection/connect with the updated config.
*/
async function reopenPluginConnection(connectionId: string, pluginId: string): Promise<void> {
const config = getConfig(connectionId);
if (!config) throw new Error("Connection config not found");
if (config.db_type !== "plugin") throw new Error("Connection is not plugin-backed");
if (config.plugin_id !== pluginId) throw new Error("Connection is owned by another plugin");
await ensureConnected(connectionId, { activate: false, forceReconnect: true });
}
function setBeforeConnectHandler(handler: BeforeConnectHandler | null) {
beforeConnectHandler = handler;
}
function sidebarDatabaseStorageRequestKey(connectionId: string, databases: readonly string[]): string {
return `${connectionId}\0${[...databases].sort().join("\0")}`;
}
function sidebarDatabaseStorageCacheScope(connectionId: string, databases: readonly string[]): MetadataScopeInput {
return { kind: "sidebar-database-storage", connectionId, extra: { databases: [...databases].sort() } };
}
function sidebarTableStorageCacheScope(scope: SidebarTableStorageScope): MetadataScopeInput {
return { kind: "sidebar-table-storage", connectionId: scope.connectionId, database: scope.database, schema: scope.schema };
}
function clearSidebarStorageCaches(connectionId: string, database?: string) {
if (database === undefined) sidebarDatabaseStorageCache.invalidate({ connectionId });
sidebarTableStorageCache.invalidate(database === undefined ? { connectionId } : { connectionId, database });
const connectionPrefix = `${connectionId}\0`;
if (database === undefined) {
for (const key of sidebarDatabaseStorageInFlight.keys()) {
if (key.startsWith(connectionPrefix)) sidebarDatabaseStorageInFlight.delete(key);
}
}
const tablePrefix = database === undefined ? connectionPrefix : `${connectionId}\0${database}\0`;
for (const key of sidebarTableStorageInFlight.keys()) {
if (key.startsWith(tablePrefix)) sidebarTableStorageInFlight.delete(key);
}
}
async function loadSidebarDatabaseStorage(connectionId: string, options?: { force?: boolean }): Promise<void> {
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 cacheScope = sidebarDatabaseStorageCacheScope(connectionId, databases);
const cached = sidebarDatabaseStorageCache.get(cacheScope);
if (!options?.force && cached) {
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;
if (sidebarDatabaseStorageInFlight.get(requestKey) !== request || !connectedIds.value.has(connectionId)) return;
sidebarDatabaseStorageCache.set(cacheScope, 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<void> {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(scope.connectionId)) return;
if (!supportsSidebarTableStorage(getConfig(scope.connectionId))) return;
const requestKey = sidebarTableStorageRequestKey(scope);
const cacheScope = sidebarTableStorageCacheScope(scope);
const cached = sidebarTableStorageCache.get(cacheScope);
if (!options?.force && cached) {
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 || !connectedIds.value.has(scope.connectionId)) return;
sidebarTableStorageCache.set(cacheScope, 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 loadXuguTablespaces(node: TreeNode, options?: LoadTreeOptions) {
if (node.type !== "group-tablespaces" || !node.connectionId) return;
const connectionId = node.connectionId;
const config = getConfig(connectionId);
if (effectiveDatabaseTypeForConnection(config) !== "xugu") return;
return runTreeMetadataLoad(
{
kind: "xugu-tablespaces",
connectionId,
database: node.database || undefined,
driverProfile: metadataDriverProfile(config),
},
async () => {
let load = beginTreeNodeLoad(node);
try {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) return;
const tablespaces = await withMetadataLoadTimeout(connectionId, api.listXuguTablespaces(connectionId, node.database || undefined), "Xugu tablespaces");
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const children = buildXuguTablespaceChildren(targetNode, tablespaces);
setChildren(targetNode, children);
targetNode.objectCount = children.length;
targetNode.isExpanded = true;
} catch (error) {
// Storage metadata is an optional, read-only enhancement. A user
// without SYS_* view access should see an empty group rather than a
// connection-level RPC error that blocks the rest of the tree.
console.debug("[DBX][xugu-tablespaces:unavailable]", { connectionId, error });
const targetNode = treeNodeLoadTarget(load);
if (targetNode) {
setChildren(targetNode, []);
targetNode.objectCount = 0;
targetNode.isExpanded = true;
}
} finally {
finishTreeNodeLoad(load);
}
},
options,
);
}
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")]);
setDatabaseCompatibilityModesFromDatabases(connectionId, databases);
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, { trustEmptyConnectionChildren: databaseNames.length > 0 && visibleNames.length === 0 });
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), { trustEmptyConnectionChildren: schemas.length > 0 && visibleSchemas.length === 0 });
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 && shouldShowDorisCatalogTree(dorisCatalogs)) {
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 {
// v3 persists per-database compatibilityMode for openGauss A-mode features.
const cacheKey = schemaCacheKey(connectionId, "databases-v3");
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");
setDatabaseCompatibilityModesFromDatabases(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),
// Cloud Spanner reports the full resource path as the database
// name; show only the database id so the sidebar stays readable.
displayLabel: effectiveDbType === "spanner" ? spannerDisplayDatabase : undefined,
});
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, { trustEmptyConnectionChildren: databases.length > 0 && visibleNames.length === 0 });
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, options?: { sidebarSearch?: boolean }): Promise<void> {
if (options?.sidebarSearch) {
return withSidebarSearchLoad(connectionId, () => loadConnectedConnectionRootForSidebarSearch(connectionId, { ...options, sidebarSearch: false }));
}
if (!connectedIds.value.has(connectionId)) return;
const config = getConfig(connectionId);
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
const node = findConnectionNode(connectionId);
if (!node || node.type !== "connection" || hasConnectionMetadataChildren(node.children)) return;
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
const inFlight = metadataLoadCoordinator.inFlightPromise<void>(scope);
if (inFlight) {
await inFlight;
return;
}
if (node.isLoading) 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, options?: { showAll?: boolean }) {
const node = findConnectionNode(connectionId);
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
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)),
);
const databaseNodes: TreeNode[] = 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: [],
}));
// #1236: a `databases` count in the low hundreds is rare but not unheard
// of, and dumping every one of them into the sidebar at once is the
// reported pain point. Cap the initial render and let a "load more" node
// reveal the rest on demand, unless the user explicitly asked to see all.
const { visible, hasMore } = options?.showAll ? { visible: databaseNodes, hasMore: false } : limitRedisDatabaseList(databaseNodes, useSettingsStore().editorSettings.redisDatabaseDisplayLimit);
setChildren(targetNode, withSavedSqlRoot(connectionId, hasMore ? [...visible, buildLoadMoreNode(targetNode, visible.length, databaseNodes.length - visible.length)] : visible, targetNode));
targetNode.isExpanded = true;
});
}
async function loadEtcdRoot(connectionId: string) {
const node = findConnectionNode(connectionId);
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const etcdAccess = await ensureEtcdAccessCapabilities(connectionId, { force: true, verifyHealth: false });
const children: TreeNode[] = [
{
id: `${connectionId}:etcd`,
label: kvRootNodeLabel("etcd"),
type: "etcd-root" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
];
if (etcdAccess.admin) {
children.push(
{
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: [],
},
);
}
setChildren(targetNode, withSavedSqlRoot(connectionId, children, targetNode));
targetNode.isExpanded = true;
});
}
async function loadZooKeeperRoot(connectionId: string) {
const node = findConnectionNode(connectionId);
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
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;
});
}
async function loadConsulRoot(connectionId: string) {
const node = findConnectionNode(connectionId);
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
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;
});
}
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: "connection.mqttConsoleTitle",
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);
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
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;
});
}
async function loadNacosNamespaces(connectionId: string, options?: LoadTreeOptions) {
const node = findConnectionNode(connectionId);
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
if (useCachedChildren(node!, options, load)) return;
const sidebarSnapshot = await api.nacosSidebarSnapshot(connectionId);
const namespaces = normalizeNacosNamespacesForDisplay(sidebarSnapshot.namespaces);
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),
);
const children: TreeNode[] = [
...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,
};
}),
];
if (sidebarSnapshot.accessControl.listUsers.supported === true || sidebarSnapshot.accessControl.listRoleBindings.supported === true) {
children.push({
id: `${connectionId}:nacos-access-control`,
label: "nacos.accessControlSidebarLabel",
type: "nacos-access-control" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
});
}
setChildren(targetNode, children);
targetNode.isExpanded = true;
});
}
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,
),
{ trustEmptyConnectionChildren: dbs.length > 0 && visibleDbs.length === 0 },
);
targetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e, load);
throw e;
} finally {
finishTreeNodeLoad(load);
}
}
async function loadDynamoDbTables(connectionId: string) {
const node = findConnectionNode(connectionId);
if (!node) return;
let load = beginTreeNodeLoad(node);
try {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
const config = getConfig(connectionId);
const region = config?.database?.trim() || "us-east-1";
const tables = await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, region), "DynamoDB tables");
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(
targetNode,
[...tables]
.sort((left, right) => compareSidebarNames(left.name, right.name))
.map((table) => ({
id: `${connectionId}:__dynamodb_table:${table.id}`,
label: table.name,
type: "dynamodb-table" as const,
connectionId,
database: region,
tableName: table.name,
isExpanded: false,
})),
);
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 isMeilisearch = getConfig(connectionId)?.db_type === "meilisearch";
const isSolr = getConfig(connectionId)?.db_type === "solr";
const collections = isMeilisearch
? sortSidebarNames(await withMetadataLoadTimeout(connectionId, api.meilisearchListIndexes(connectionId), "Meilisearch indexes")).map((name) => ({ name, aliases: [] as string[] }))
: [...(await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, "default"), isSolr ? "Solr cores" : "Elasticsearch indices"))].sort((left, right) => compareSidebarNames(left.name, right.name));
const indexNodes = collections.map((collection) => {
const aliases = collection.aliases?.filter((alias) => alias.trim());
return {
id: `${connectionId}:__collection:${collection.name}`,
label: collection.name,
type: "elasticsearch-index" as const,
connectionId,
database: "default",
isExpanded: false,
...(aliases?.length ? { searchAliases: aliases } : {}),
};
});
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(
targetNode,
withSavedSqlRoot(
connectionId,
[
...indexNodes,
...(isMeilisearch
? [
{
id: `${connectionId}:__meilisearch_system`,
label: "meilisearch.systemManagement",
type: "meilisearch-system" as const,
connectionId,
database: "default",
isExpanded: false,
},
]
: []),
],
targetNode,
),
);
targetNode.isExpanded = true;
} catch (e) {
const targetNode = treeNodeLoadTarget(load);
if (targetNode && getConfig(connectionId)?.db_type === "meilisearch") {
const existingChildren = (targetNode.children || []).filter((child) => child.type !== "meilisearch-system");
setChildren(
targetNode,
withSavedSqlRoot(
connectionId,
[
...existingChildren,
{
id: `${connectionId}:__meilisearch_system`,
label: "meilisearch.systemManagement",
type: "meilisearch-system",
connectionId,
database: "default",
isExpanded: false,
},
],
targetNode,
),
);
targetNode.isExpanded = true;
}
recordMetadataLoadError(connectionId, e, load);
throw e;
} finally {
finishTreeNodeLoad(load);
}
}
async function loadMilvusDatabases(connectionId: string) {
const node = findConnectionNode(connectionId);
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
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;
});
}
async function loadVectorCollections(connectionId: string, database?: string) {
const config = getConfig(connectionId);
const isMilvus = config?.db_type === "milvus";
const effectiveDb = database || config?.database || (config?.db_type === "chromadb" ? "default_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);
return runConnectionTreeMetadataLoad(connectionId, node, async (load) => {
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;
});
}
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 collectionEntries = visibleMongoCollections(collections);
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;
// schemas-v5 invalidates cached children created before the public
// synonym scope was placed after all real schemas.
const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "xugu" ? "schemas-v5" : "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"), effectiveDatabaseTypeForConnection(config) === "spanner");
const visibleSchemaNames = new Set(
filterSchemaNamesForConnection(
schemas.map((schema) => schema.name),
getConfig(connectionId),
database,
{ showSystemSchemas },
),
);
// The public-synonym scope is a protocol namespace, not a user
// schema. Keep it discoverable even when a visible-schema filter is
// configured, while preserving the raw key for object routing.
if (config?.db_type === "xugu" && schemas.some((schema) => isXuguPublicSynonymScope(schema.name))) {
visibleSchemaNames.add(XUGU_PUBLIC_SYNONYM_SCOPE);
}
if (config?.db_type === "xugu" && schemas.some((schema) => isXuguSchedulerJobScope(schema.name))) {
visibleSchemaNames.add(XUGU_SCHEDULER_JOB_SCOPE);
}
const children: TreeNode[] = schemas
.filter((schema) => visibleSchemaNames.has(schema.name))
.map((schema) => {
const s = schema.name;
return {
id: `${connectionId}:${database}:${s}`,
label: config?.db_type === "xugu" ? xuguSchemaDisplayName(s) : config?.db_type === "spanner" ? spannerSchemaDisplayName(s) : 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, database) : undefined;
const searchFilterForScope = activeTreeLoadSearchFilter(options);
const pageSizeForScope = sidebarObjectGroupPageSize();
return runTreeMetadataLoad(
{
kind: "schema-tables",
connectionId,
database,
schema: undefined,
nodeKind: "database",
objectTypes: objectTypesForScope,
searchFilter: searchFilterForScope,
limit: simpleObjectDisplayForScope ? (searchFilterForScope ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : pageSizeForScope + 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-v6");
if (!options?.force && !searchFilter && !tableNameFilter) {
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const pageSize = pageSizeForScope;
const fetchLimit = searchFilter ? SIDEBAR_TABLE_SEARCH_RESULT_BUDGET : 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 ? sidebarObjectTypesForScope(configForScope, database, schema) : 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) {
// `schema != null`, not truthiness: Cloud Spanner's GoogleSQL default schema is the empty
// string, and its node id carries the trailing separator (`conn:db:`). A truthiness test
// collapses that to the database node id, so the loaded tables were attached to the
// database node instead of the schema node, leaving `(default)` permanently empty.
const nodeId = schema != null ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
const cacheKey = schemaCacheKey(connectionId, database, schema || "", objectTreeCacheVersion(configForScope, database, schema, "objects-simple-v9"));
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 != null ? "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 () => {
// `schema != null`, not truthiness: Cloud Spanner's GoogleSQL default schema is the empty
// string, and its node id carries the trailing separator (`conn:db:`). A truthiness test
// collapses that to the database node id, so the loaded tables were attached to the
// database node instead of the schema node, leaving `(default)` permanently empty.
const nodeId = schema != null ? `${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 objectTreeProfile = driverProfileObjectTreeProfileForConnection(config);
const isPublicSynonymScope = config?.db_type === "xugu" && isXuguPublicSynonymScope(schema);
const isSchedulerJobScope = config?.db_type === "xugu" && isXuguSchedulerJobScope(schema);
const baseCacheVersion = objectTreeCacheVersion(config, database, schema, simpleObjectDisplay ? "objects-simple-v9" : "objects-grouped-v9");
const cacheVersion = !simpleObjectDisplay && objectTreeProfile?.cacheKey ? `${baseCacheVersion}:${objectTreeProfile.cacheKey}` : baseCacheVersion;
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 ? sidebarObjectTypesForScope(config, database, schema).filter((objectType) => objectType !== "TABLE") : [];
let children: TreeNode[];
let nextObjectCount: number | undefined;
if (simpleObjectDisplay && !isPublicSynonymScope && !isSchedulerJobScope) {
const pageSize = sidebarObjectGroupPageSize();
const page = await loadPagedSimpleTableChildren({
nodeId,
connectionId,
database,
querySchema,
effectiveSchema,
nonTableObjectTypes,
offset: 0,
pageSize,
searchFilter: options?.searchFilter === "" ? "" : searchFilter || undefined,
pagedSearch: isSidebarTableSearch,
force: options?.force,
});
children = page.hasMore && (!searchFilter || isSidebarTableSearch) ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children;
nextObjectCount = page.objectCount;
} else if (simpleObjectDisplay) {
// The synthetic public scope contains no tables. Avoid issuing a
// table-list query against the protocol namespace; supplemental
// object loading below will add only its SYNONYM entries.
children = [];
nextObjectCount = 0;
} else {
children = buildObjectGroupPlaceholderNodes({
nodeId,
connectionId,
database,
schema: effectiveSchema,
objectTypes: sidebarObjectTypesForScope(config, database, schema),
groupOverrides: objectTreeProfile?.groupOverrides,
});
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);
// grouped 显示下这里加载的是分组占位节点;xugu 协议命名空间的空列表也不代表表被删。
pruneTableVGroupStaleMembers(targetNode, children, simpleObjectDisplay && !isPublicSynonymScope && !isSchedulerJobScope);
}
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): Promise<void> {
if (options?.sidebarSearch) {
return withSidebarSearchLoad(node.connectionId, () => loadObjectGroupChildren(node, { ...options, sidebarSearch: false }));
}
// Queued search/refresh tasks can outlive disconnect, which removes their nodes.
if (!treeNodeInSidebarTree(node)) return;
const packageOwnerId = packageMemberGroupOwnerId(node);
const packageConfig = node.connectionId ? getConfig(node.connectionId) : undefined;
if (packageOwnerId && effectiveDatabaseTypeForConnection(packageConfig) === "xugu") {
const packageNode = findNode(treeNodes.value, packageOwnerId);
if (packageNode?.type === "package") await loadPackageMembers(packageNode, options);
return;
}
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))) {
forgetFilteredObjectGroupChildren(node.id);
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)) {
forgetFilteredObjectGroupChildren(node.id);
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) {
forgetFilteredObjectGroupChildren(node.id);
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: options?.searchFilter === "" ? "" : searchFilter || undefined,
pagedSearch: isSidebarTableSearch,
force: options?.force,
});
children = page.hasMore && (!searchFilter || isSidebarTableSearch) ? 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;
if (searchFilter && !filteredObjectGroupChildrenIds.has(targetNode.id) && isTreeNodeChildrenLoaded(targetNode.id)) {
// Capture the first transition into a filtered projection only; later
// keystrokes re-filter the same group and must keep the original list.
filteredObjectGroupChildrenSnapshots.set(targetNode.id, {
children: targetNode.children ?? [],
objectCount: targetNode.objectCount,
});
}
targetNode.objectCount = nextObjectCount;
setChildren(targetNode, children);
if (searchFilter) filteredObjectGroupChildrenIds.add(targetNode.id);
else forgetFilteredObjectGroupChildren(targetNode.id);
options?.onChildrenApplied?.(targetNode);
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
await savePersistedTreeChildren(cacheKey, children);
pruneTableVGroupStaleMembers(targetNode, children, tableVGroupKindOfContainerNode(targetNode) !== null);
}
const currentTargetNode = treeNodeLoadTarget(load);
if (currentTargetNode) currentTargetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(node.connectionId, e, load);
throw e;
} finally {
finishTreeNodeLoad(load);
}
},
options,
);
}
async function loadCustomTypeChildren(node: TreeNode, options?: LoadTreeOptions) {
if (node.type !== "type" || !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 schema = node.schema || node.database;
const details = await api.getCustomTypeDetails(node.connectionId, node.database, schema, node.objectName || node.label);
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const children = buildCustomTypeTreeChildren(targetNode, details);
targetNode.customTypeKind = details.kind;
targetNode.hasMembers = children.length > 0;
setChildren(targetNode, children);
targetNode.isExpanded = children.length > 0;
} catch (error) {
recordMetadataLoadError(node.connectionId, error, load);
throw error;
} finally {
finishTreeNodeLoad(load);
}
}
async function loadMoreObjectGroupChildren(node: TreeNode, options?: { searchFilter?: string }) {
if (node.type !== "load-more" || !node.loadMore) return;
const loadMore = node.loadMore;
const parent = findNode(treeNodes.value, node.loadMore.parentId);
if (parent?.type === "connection" && parent.connectionId && getConfig(parent.connectionId)?.db_type === "redis") {
// The full database list is already fetched in one cheap call, so "load
// more" here just re-renders it without the display-limit truncation
// instead of paging through another backend round trip.
node.isLoading = true;
try {
await loadRedisDatabases(parent.connectionId, { showAll: true });
} finally {
node.isLoading = false;
}
return;
}
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,
searchFilter: options?.searchFilter,
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,
searchFilter: options?.searchFilter,
pagedSearch: !!options?.searchFilter,
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);
if (!options?.searchFilter) {
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v9")), nextChildren);
}
// 该分支只服务 simple 库/模式表列表;搜索分页结果不是全量,不能作为成员依据。
if (!page.hasMore && !options?.searchFilter) pruneTableVGroupStaleMembers(targetParent, nextChildren, true);
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,
searchFilter: options?.searchFilter,
pagedSearch: !!options?.searchFilter,
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,
searchFilter: options?.searchFilter,
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);
if (!options?.searchFilter) {
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
// procedures/triggers 等对象组在此分支提前 return,回收必须在 return 前;
// 非分组容器由 prune 内部的类别防护挡下。
pruneTableVGroupStaleMembers(targetParent, nextChildren, tableVGroupKindOfContainerNode(parent) !== null);
}
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);
if (!options?.searchFilter) {
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
// 各分组容器按自身类别回收完整列表的失效成员;分页中间态(含
// load-more)由 prune 内部再挡一次。
pruneTableVGroupStaleMembers(targetParent, nextChildren, tableVGroupKindOfContainerNode(parent) !== null);
}
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<boolean> {
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 pageSize = sidebarObjectGroupPageSize();
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
// A query tab can reach locate without a schema (a toolbar "new query" tab,
// or a reopened .sql file), and on a schema tree the table then lives under
// one of the database's schema nodes. Resolve the schema per node instead of
// guessing a single one for the whole database: the old `schema || database`
// guess matched nothing at all (issue #7648), and a single resolved schema
// would merge one schema's tables into every other schema's node.
const schemaForNode = (nodeSchema?: string) => target.schema ?? nodeSchema;
let loaded = false;
if (simpleObjectDisplay) {
const schemaParents = target.schema
? [findNode(treeNodes.value, `${target.connectionId}:${target.database}:${target.schema}`)]
: findTreeNodes(treeNodes.value, (node) => node.type === "schema" && node.connectionId === target.connectionId && sameSidebarObjectName(node.database, target.database));
const parents = schemaParents.filter((node): node is TreeNode => !!node);
if (!parents.length) {
const databaseParent = target.schema ? null : findNode(treeNodes.value, `${target.connectionId}:${target.database}`);
if (databaseParent) parents.push(databaseParent);
}
if (!parents.length) return false;
for (const parent of parents) {
const parentSchema = schemaForNode(parent.type === "schema" ? (parent.schema ?? parent.label) : undefined);
let load = beginTreeNodeLoad(parent);
try {
load = reclaimTreeNodeLoad(load, parent);
const page = await loadPagedSimpleTableChildren({
nodeId: parent.id,
connectionId: target.connectionId,
database: target.database,
querySchema: connectionObjectTreeQuerySchema(config, target.database, parentSchema),
effectiveSchema: connectionObjectTreeNodeSchema(config, target.database, parentSchema),
nonTableObjectTypes: [],
offset: 0,
pageSize,
searchFilter: target.tableName,
force: false,
});
if (!page.children.length) continue;
const targetParent = treeNodeLoadTarget(load);
if (!targetParent) continue;
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;
loaded = true;
} finally {
finishTreeNodeLoad(load);
}
}
return loaded;
}
const matchingGroups = findTreeNodes(treeNodes.value, (node) => {
return (
(node.type === "group-tables" || node.type === "group-dolt-system-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 groupSchema = schemaForNode(group.schema);
const page = await loadPagedTableGroupChildren({
node: group,
parentNodeId,
querySchema: connectionObjectTreeQuerySchema(config, group.database || target.database, groupSchema),
effectiveSchema: connectionObjectTreeNodeSchema(config, group.database || target.database, groupSchema),
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;
const liveParent = treeNodeInSidebarTree(parent);
if (!liveParent) return;
if (!isTreeNodeChildrenLoaded(liveParent.id) || filteredObjectGroupChildrenIds.has(liveParent.id)) {
let isPreparedPageCurrent: (() => boolean) | undefined;
await loadObjectGroupChildren(liveParent, {
force: true,
searchFilter: "",
allowGlobalSearchMismatch: true,
onChildrenApplied: (targetNode) => {
const epoch = treeNodeLoads.observe(targetNode.id);
isPreparedPageCurrent = () => epoch.isCurrent();
},
});
if (!isPreparedPageCurrent?.()) return;
}
const refreshedParent = treeNodeInSidebarTree(parent);
if (!refreshedParent) return;
const load = beginTreeNodeLoad(refreshedParent);
try {
const liveParent = treeNodeLoadTarget(load);
if (!liveParent) return;
let loadMoreNode = findTreeNodes(liveParent.children ?? [], (child) => child.type === "load-more")[0];
while (loadMoreNode?.loadMore) {
await loadMoreObjectGroupChildren(loadMoreNode, { searchFilter: "" });
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(refreshedParent.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 || effectiveDbType === "oceanbase-oracle") {
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;
setLoadedTableMetadataChildren(
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;
const connConfig = getConfig(connectionId);
const isGaussdbM = effectiveDatabaseTypeForConnection(connConfig) === "gaussdb" && connConfig?.driver_profile?.toLowerCase() === "gaussdb-m";
setLoadedTableMetadataChildren(
targetNode,
columns.map((col) => ({
id: `${parentId}:${col.name}`,
label: `${col.name} (${isGaussdbM ? gaussdbMTypeDisplayName(col.data_type) : 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;
setLoadedTableMetadataChildren(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;
setLoadedTableMetadataChildren(
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;
setLoadedTableMetadataChildren(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;
setLoadedTableMetadataChildren(
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;
setLoadedTableMetadataChildren(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;
const isXugu = effectiveDatabaseTypeForConnection(getConfig(connectionId)) === "xugu";
setLoadedTableMetadataChildren(
targetNode,
triggers.map((tr) => {
const xuguDetails = isXugu ? [tr.timing, tr.event, tr.level, tr.enabled === false ? i18n.global.t("objects.disabled") : null, tr.valid === false ? i18n.global.t("objects.invalid") : null].filter(Boolean).join(" · ") : `${tr.timing} ${tr.event}`;
return {
id: `${parentId}:${tr.name}`,
label: `${tr.name} (${xuguDetails})`,
objectName: tr.name,
type: "trigger" as const,
connectionId,
database,
schema,
tableName: table,
comment: isXugu ? tr.comment : undefined,
valid: isXugu ? tr.valid : undefined,
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;
setLoadedTableMetadataChildren(
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;
setLoadedTableMetadataChildren(
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;
setLoadedTableMetadataChildren(
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<string>()): Set<string> {
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): Promise<void> {
if (options?.sidebarSearch) {
return withSidebarSearchLoad(node.connectionId, () => loadTreeNodeChildren(node, { ...options, sidebarSearch: false }));
}
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 === "dynamodb") {
await loadDynamoDbTables(node.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch" || config?.db_type === "solr") {
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 === "oracle-db-links") {
await loadOracleDatabaseLinks(node, 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 === "group-tablespaces" && node.connectionId) {
await loadXuguTablespaces(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) && schemaNodeHasLoadableName(effectiveDatabaseTypeForConnection(getConfig(node.connectionId)), 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 === "type" && isXuguTypeMemberContainer(node, getConfig(node.connectionId || "")?.db_type)) {
// Xugu object types expose attributes and methods through the scoped
// completion endpoint. Do not route them through the generic custom-type
// loader, which treats the type name as a table and issues getColumns.
await loadXuguTypeMembers(node, options);
} else if (node.type === "type") {
await loadCustomTypeChildren(node, options);
} 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<string>, 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, options?: { skipObjectCacheInvalidation?: boolean; sidebarSearch?: boolean }): Promise<void> {
if (options?.sidebarSearch) {
return withSidebarSearchLoad(node.connectionId, () => refreshTreeNode(node, { ...options, sidebarSearch: false }));
}
invalidateCompletionCachesForNode(node);
invalidateMetadataCachesForNode(node, options);
if (objectTypesForGroupNode(node.type)) {
clearLoadedChildrenCache(node.id, { deletePersisted: false });
await loadObjectGroupChildren(node, { force: true });
await invalidateSidebarTableSearchIndexesForTreeRefresh(node);
return;
}
const parentId = objectGroupRefreshParentId(node);
const parentNode = parentId ? findNode(treeNodes.value, parentId) : null;
if (parentNode) {
await refreshTreeNode(parentNode, options);
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);
if (isCurrentRefresh()) await invalidateSidebarTableSearchIndexesForTreeRefresh(node);
}
} 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);
}
}
}
/**
* Connection-node refresh: fully invalidate the connection's object caches
* (DDL + object metadata, memory and persisted) and surface deletion
* failures before the tree reload runs. Tree-reload failures propagate
* unmarked and keep the original connect-failure handling.
*/
async function refreshConnectionTreeNode(node: TreeNode): Promise<void> {
if (node.type !== "connection" || !node.connectionId) return refreshTreeNode(node);
await invalidateObjectDdlCache({ connectionId: node.connectionId }, { strict: true });
await refreshTreeNode(node, { skipObjectCacheInvalidation: true });
}
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<string, unknown>, 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 {
const databaseType = getConfig(connectionId)?.db_type;
if (databaseType === "oracle" || databaseType === "oceanbase-oracle") {
const normalizedTable = context?.tableQuoted === false ? table.toUpperCase() : table;
const normalizedSchema = schema && context?.schemaQuoted === false ? schema.toUpperCase() : (schema ?? "");
return `${connectionId}:${database}:${catalog ?? ""}:${normalizedSchema}:${normalizedTable}`;
}
const baseKey = `${completionTableScopeKey(connectionId, database, schema, catalog)}:${table.toLowerCase()}`;
if (databaseType !== "postgres" || (!context?.tableQuoted && !context?.schemaQuoted)) return baseKey;
const quotedSchema = context.schemaQuoted ? encodeURIComponent(schema ?? "") : "";
const quotedTable = context.tableQuoted ? encodeURIComponent(table) : "";
return `${baseKey}:quoted:s=${quotedSchema}:t=${quotedTable}`;
}
function completionColumnPrefixKey(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): string {
return `${completionColumnsKey(connectionId, database, table, schema, catalog, context)}:prefix:${prefix.trim().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 {
bumpCompletionCacheRevision(connectionId, database);
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++;
}
}
for (const key of completionColumnPrefixIndex.keys()) {
const prefixMarker = key.toLowerCase().lastIndexOf(":prefix:");
const baseKey = prefixMarker >= 0 ? key.slice(0, prefixMarker) : key;
if (!matches(baseKey)) continue;
completionColumnPrefixIndex.delete(key);
removed++;
}
return removed;
}
function invalidateCompletionCachesForNode(node: TreeNode) {
if (!node.connectionId) return;
if (typeof node.database !== "string") {
invalidateCompletionCache(node.connectionId);
return;
}
if (node.tableName) {
invalidateCompletionTableCache(node.connectionId, node.database, node.tableName, node.schema, node.catalog);
return;
}
invalidateCompletionCache(node.connectionId, node.database);
}
function touchCompletionIndex<T>(index: Map<string, { touched: number } & T>, 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<T>(key: string, load: () => Promise<T>, limit?: { scope: string; kind: string }): Promise<T> {
const existing = completionInFlight.get(key) as Promise<T> | 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 ?? "",
parent_type: request.parent_type ?? "",
match_mode: request.match_mode ?? "prefix",
});
}
async function completionAssistantSearch(request: CompletionAssistantRequest, requestRevision = completionCacheRevision(request.connection_id, request.database)) {
return withCompletionInFlight(`${request.connection_id}:${request.database}:assistant:${requestRevision}:${completionAssistantRequestKey(request)}`, async () => {
await ensureConnected(request.connection_id);
return api.completionAssistantSearch(request);
});
}
async function loadPackageMembers(node: TreeNode, options?: LoadTreeOptions): Promise<void> {
if (node.type !== "package" || !node.connectionId || !node.database) return;
const databaseType = effectiveDatabaseTypeForConnection(getConfig(node.connectionId));
if (!supportsPackageMemberExpansion(databaseType, databaseCompatibilityMode(node.connectionId, node.database))) 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,
...(databaseType === "xugu" ? { parent_type: "package" as const } : {}),
match_mode: "prefix",
});
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(targetNode, buildPackageMemberNodes(targetNode, response.candidates, databaseType));
targetNode.isExpanded = true;
},
options,
);
} catch (error) {
recordMetadataLoadError(connectionId, error, load);
throw error;
} finally {
finishTreeNodeLoad(load);
}
}
async function loadXuguTypeMembers(node: TreeNode, options?: Pick<LoadTreeOptions, "force" | "preserveCollapsedChildren">): Promise<void> {
if (!isXuguTypeMemberContainer(node, getConfig(node.connectionId || "")?.db_type)) return;
const connectionId = node.connectionId;
const database = node.database;
if (!connectionId || !database) return;
if (node.isExpanded && !options?.force) {
node.isExpanded = false;
if (!sidebarSearchQuery.value && !options?.preserveCollapsedChildren) releaseCollapsedTreeNodeChildren(node.id);
return;
}
if (!options?.force && node.children && node.children.length > 0) {
node.isExpanded = true;
return;
}
const schema = node.schema || "";
const parentName = node.objectName || node.label;
let load = beginTreeNodeLoad(node);
try {
await runTreeMetadataLoad(
{
kind: "xugu-type-members",
connectionId,
database,
schema,
nodeKind: node.type,
extra: { typeName: parentName },
},
async () => {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
const [attributes, methods] = await Promise.all([
completionAssistantSearch({
connection_id: connectionId,
database,
schema,
object_kinds: ["column"],
mask: "",
max_results: 500,
global_search: false,
parent_schema: schema,
parent_name: parentName,
parent_type: "type",
match_mode: "prefix",
}),
completionAssistantSearch({
connection_id: connectionId,
database,
schema,
object_kinds: ["routine"],
mask: "",
max_results: 500,
global_search: false,
parent_schema: schema,
parent_name: parentName,
parent_type: "type",
match_mode: "prefix",
}),
]);
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
const children = buildXuguTypeMemberNodes(targetNode, [...attributes.candidates, ...methods.candidates], {
attributes: "tree.attributes",
methods: "tree.methods",
});
setChildren(targetNode, children);
targetNode.isExpanded = children.length > 0;
if (children.length === 0) targetNode.xuguTypeMembersExpandable = false;
},
);
} 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<DatabaseType>(["mysql", "postgres", "sqlserver", "oracle", "opengauss"]);
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 detail = completionTableDetail(candidate.comment);
const table: SqlCompletionTable = {
name: candidate.name,
schema: candidate.schema ?? undefined,
type: sqlObjectNavigationTypeFromTableType(candidate.data_type || candidate.kind),
...(detail ? { detail } : {}),
...completionStableTableType(candidate.data_type),
};
if (!withOracleMetadata) return table;
const metadataDetail = candidate.schema ? `${candidate.schema} · ${(candidate.data_type || candidate.kind).toLowerCase()}` : candidate.kind;
return {
...table,
detail: table.detail ? `${metadataDetail} ${table.detail}` : metadataDetail,
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 === "sequence" ? "sequence" : 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,
}));
}
/**
* The completion assistant matches names by prefix only, while the warm local
* index also matches substrings. Without widening, the very first fuzzy lookup
* of a connection (empty local index) returns far fewer candidates than the
* same lookup once the index is warm. Widening to a substring search keeps the
* two paths consistent whenever the prefix search left room in the result list.
*/
function shouldWidenCompletionMatch(filter: string, resultCount: number, limit?: number): boolean {
if (filter.trim().length < 3) return false;
return limit === undefined || resultCount < limit;
}
async function listCompletionAssistantTables(
connectionId: string,
database: string,
filter: string,
limit?: number,
schema?: string,
globalSearch = false,
currentSchema?: string,
requestRevision = completionCacheRevision(connectionId, database),
matchMode: CompletionAssistantMatchMode = "prefix",
): Promise<SqlCompletionTable[]> {
const oracleAssistant = isOracleCompletionDatabase(getConfig(connectionId)?.db_type);
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: matchMode,
},
requestRevision,
);
const tables = completionAssistantTables(response.candidates, preferredSchema, oracleAssistant);
if (requestRevision === completionCacheRevision(connectionId, database)) 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[],
caseSensitive: boolean,
matchMode: CompletionAssistantMatchMode = "prefix",
): Promise<SqlCompletionObject[]> {
const databaseType = getConfig(connectionId)?.db_type;
const oracleAssistant = isOracleCompletionDatabase(databaseType);
const requestedSchema = schema?.trim() || currentSchema?.trim() || undefined;
const sequenceOnly = objectKinds.length === 1 && objectKinds[0] === "sequence";
const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, currentSchema) : requestedSchema || (!sequenceOnly && 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(),
case_sensitive: caseSensitive,
max_results: limit ?? 200,
global_search: globalSearch,
parent_schema: globalSearch || sequenceOnly ? null : (schema ?? null),
parent_name: parentName ?? null,
match_mode: matchMode,
});
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 }, requestRevision = completionCacheRevision(connectionId, database)): Promise<SqlCompletionColumn[]> {
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",
},
requestRevision,
);
const columns = completionAssistantColumns(response.candidates, table, schema, context);
if (columns.length > 0 && requestRevision === completionCacheRevision(connectionId, database)) indexCompletionColumns(connectionId, database, table, schema, columns, undefined, context);
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();
const 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<string, SqlCompletionTable[]>();
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<string, SqlCompletionObject[]>();
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, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }) {
touchCompletionIndex(completionColumnIndex, completionColumnsKey(connectionId, database, table, schema, catalog, context), {
columns,
});
}
function indexCompletionColumnPrefix(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, columns: SqlCompletionColumn[], complete: boolean, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }) {
touchCompletionIndex(completionColumnPrefixIndex, completionColumnPrefixKey(connectionId, database, table, schema, prefix, catalog, context), { columns, complete });
}
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<string>();
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 completionColumnPrefixEntry(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): { columns: SqlCompletionColumn[]; complete: boolean } | undefined {
const normalizedPrefix = prefix.trim().toLowerCase();
const exact = completionColumnPrefixIndex.get(completionColumnPrefixKey(connectionId, database, table, schema, normalizedPrefix, catalog, context));
if (exact) return exact;
const marker = `${completionColumnsKey(connectionId, database, table, schema, catalog, context)}:prefix:`;
let reusable: { prefix: string; columns: SqlCompletionColumn[] } | undefined;
for (const [key, entry] of completionColumnPrefixIndex) {
if (!entry.complete || !key.startsWith(marker)) continue;
const cachedPrefix = key.slice(marker.length);
if (!normalizedPrefix.startsWith(cachedPrefix) || (reusable && cachedPrefix.length <= reusable.prefix.length)) continue;
reusable = { prefix: cachedPrefix, columns: entry.columns };
}
if (!reusable) return undefined;
return {
columns: reusable.columns.filter((column) => column.name.toLowerCase().startsWith(normalizedPrefix)),
complete: true,
};
}
function lookupLocalCompletionColumnsByPrefix(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): SqlCompletionColumn[] {
return completionColumnPrefixEntry(connectionId, database, table, schema, prefix, catalog, context)?.columns ?? [];
}
function lookupLocalCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): SqlCompletionForeignKey[] {
return completionForeignKeyIndex.get(completionForeignKeysKey(connectionId, database, table, schema))?.foreignKeys ?? [];
}
async function listCompletionColumnsByPrefix(connectionId: string, database: string, table: string, schema: string | undefined, prefix: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): Promise<SqlCompletionColumn[]> {
const normalizedPrefix = prefix.trim();
if (normalizedPrefix.length < 2) return [];
const databaseType = effectiveDatabaseTypeForConnection(getConfig(connectionId));
if (databaseType !== "postgres" && databaseType !== "mysql") return [];
const completionTable = table;
const completionSchema = schema?.trim() || (databaseType === "mysql" ? database : undefined);
const cacheKey = completionColumnPrefixKey(connectionId, database, completionTable, completionSchema, normalizedPrefix, catalog, context);
const cached = completionColumnPrefixEntry(connectionId, database, completionTable, completionSchema, normalizedPrefix, catalog, context);
if (cached) return cached.columns;
const requestRevision = completionCacheRevision(connectionId, database);
return withCompletionInFlight(
`column-prefix:${requestRevision}:${cacheKey}`,
async () => {
const existing = completionColumnPrefixIndex.get(cacheKey);
if (existing) return existing.columns;
await ensureConnected(connectionId);
const response = await completionAssistantSearch(
{
connection_id: connectionId,
database,
schema: completionSchema ?? null,
object_kinds: ["column"],
mask: normalizedPrefix,
max_results: 128,
parent_schema: completionSchema ?? null,
parent_name: completionTable,
match_mode: "prefix",
},
requestRevision,
);
const columns = completionAssistantColumns(response.candidates, completionTable, completionSchema, context);
if (requestRevision === completionCacheRevision(connectionId, database)) {
indexCompletionColumnPrefix(connectionId, database, completionTable, completionSchema, normalizedPrefix, columns, !response.incomplete, catalog, context);
}
return columns;
},
{ scope: completionLimiterScope(connectionId, database), kind: "column-prefix" },
);
}
function databaseNamesFromTree(connectionId: string): string[] {
const node = findConnectionNode(connectionId);
if (!node?.children) return [];
const seen = new Set<string>();
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<string[]> {
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);
setDatabaseCompatibilityModesFromDatabases(connectionId, databases);
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<SqlServerCompletionContext> {
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<string[]> {
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<string[]> {
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];
}
async function listElasticsearchCompletionFields(connectionId: string, index: string): Promise<ElasticsearchCompletionField[]> {
if (!index) return [];
const cacheKey = `${connectionId}:${index}`;
const cached = elasticsearchCompletionFieldsCache.value[cacheKey];
if (cached) return cached;
return withCompletionInFlight(`${cacheKey}:es-fields`, async () => {
await ensureConnected(connectionId);
const mapping = await api.elasticsearchGetIndexMetadata(connectionId, index, "mapping");
const fields = flattenElasticsearchMappingFields(mapping);
elasticsearchCompletionFieldsCache.value[cacheKey] = fields;
evictOldestCacheEntries(elasticsearchCompletionFieldsCache.value, COMPLETION_CACHE_MAX);
return fields;
});
}
// 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;
// `\\xNN` is binary only when it has an even number of preceding slashes.
const BINARY_REDIS_KEY_ESCAPE = /(^|[^\\])(?:\\\\)*\\x[0-9a-f]{2}/i;
async function listRedisCompletionKeys(connectionId: string, database: string): Promise<string[]> {
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)
.filter((key) => !BINARY_REDIS_KEY_ESCAPE.test(key))
.slice(0, REDIS_COMPLETION_KEYS_MAX);
redisCompletionKeysCache.value[cacheKey] = keys;
evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX);
return keys;
});
}
async function listRedisCompletionCommandDocs(connectionId: string, database: string): Promise<RedisCommandDocumentation[]> {
const cached = redisCommandDocsCache.value[connectionId];
if (cached) return cached;
return withCompletionInFlight(`${connectionId}:redis-command-docs`, async () => {
const generation = redisCommandDocsCacheGeneration.get(connectionId) ?? 0;
await ensureConnected(connectionId);
const db = Number.parseInt(database, 10) || 0;
let docs: RedisCommandDocumentation[];
try {
// Redis recommends COMMAND DOCS for complete, version-aware client metadata.
const docsResult = await api.redisExecuteCommand(connectionId, db, "COMMAND DOCS");
docs = parseRedisCommandDocumentation(docsResult.value);
try {
const catalogResult = await api.redisExecuteCommand(connectionId, db, "COMMAND");
docs = mergeRedisCommandDocumentation(docs, parseRedisCommandCatalog(catalogResult.value));
} catch {
// Documentation still provides useful grammar when COMMAND is restricted.
}
} catch (docsError) {
// Redis before 7.0 lacks COMMAND DOCS; COMMAND still reports its actual command inventory.
try {
const result = await api.redisExecuteCommand(connectionId, db, "COMMAND");
docs = parseRedisCommandCatalog(result.value);
} catch {
throw docsError;
}
}
if ((redisCommandDocsCacheGeneration.get(connectionId) ?? 0) === generation) {
redisCommandDocsCache.value[connectionId] = docs;
evictOldestCacheEntries(redisCommandDocsCache.value, COMPLETION_CACHE_MAX);
}
return docs;
});
}
async function listMongoCompletionCollections(connectionId: string, database: string): Promise<string[]> {
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<MongoCompletionField[]> {
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<TableInfo[]> {
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; verifySchemaMetadata?: boolean } = {}): Promise<SqlCompletionTable[]> {
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 ?? ""}:${options.verifySchemaMetadata ? "verified" : "indexed"}`;
const cachedTables = completionTablesCache.value[cacheKey];
if (cachedTables) {
const localTables = lookupLocalCompletionTables(connectionId, database, trimmedFilter, limit, schema, catalog);
if (localTables.length === 0) return cachedTables;
const mergedTables = dedupeCompletionTables([...localTables, ...cachedTables]);
completionTablesCache.value[cacheKey] = limit ? mergedTables.slice(0, limit) : mergedTables;
indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey], catalog);
return completionTablesCache.value[cacheKey];
}
const requestRevision = completionCacheRevision(connectionId, database);
return withCompletionInFlight(
`${cacheKey}:tables`,
async () => {
await ensureConnected(connectionId, { activate: options.activateConnection !== false });
if (isSchemaAwareDatabase(connectionId)) {
if (normalizedFilter || limit) {
let results: SqlCompletionTable[] = [];
let assistantCompleted = false;
try {
results = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema, requestRevision);
assistantCompleted = true;
if (shouldWidenCompletionMatch(trimmedFilter, results.length, limit)) {
try {
const widenedTables = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema, requestRevision, "contains");
results = dedupeCompletionTables([...results, ...widenedTables]);
} catch {
// Keep the prefix matches when the widened lookup is unavailable.
}
}
} catch {
if (schema) {
const tables = await listCompletionTableMetadata(connectionId, database, schema, trimmedFilter, limit, catalog);
results = tableInfosToCompletionTables(tables, schema, catalog);
} else {
results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog);
}
}
if (assistantCompleted && schema && options.verifySchemaMetadata) {
try {
const tables = await listCompletionTableMetadata(connectionId, database, schema, trimmedFilter, limit, catalog);
results = dedupeCompletionTables([...tableInfosToCompletionTables(tables, schema, catalog), ...results]);
} catch {
// The completion index still provides a best-effort result when
// authoritative metadata is temporarily unavailable.
}
}
if (results.length === 0 && relaxedFilter) {
if (globalSearch) {
try {
results = await listCompletionAssistantTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), schema, true, currentSchema, requestRevision);
} catch {
results = [];
}
} else if (schema) {
try {
const tables = await listCompletionTableMetadata(connectionId, database, schema, relaxedFilter, expandedCompletionLimit(limit), catalog);
results = tableInfosToCompletionTables(tables, schema, catalog);
} catch {
results = [];
}
} else {
results = lookupLocalCompletionTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), undefined, catalog);
}
}
const limitedTables = limit ? dedupeCompletionTables(results).slice(0, limit) : results;
if (requestRevision !== completionCacheRevision(connectionId, database)) return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog, options);
completionTablesCache.value[cacheKey] = limitedTables;
indexCompletionTables(connectionId, database, undefined, limitedTables, catalog);
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
return completionTablesCache.value[cacheKey];
}
let scopedTables: SqlCompletionTable[];
if (schema) {
const tables = await listCompletionTableMetadata(connectionId, database, schema, undefined, undefined, catalog);
scopedTables = tableInfosToCompletionTables(tables, schema, catalog);
} else {
scopedTables = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog);
}
if (requestRevision !== completionCacheRevision(connectionId, database)) return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog, options);
completionTablesCache.value[cacheKey] = scopedTables;
indexCompletionTables(connectionId, database, undefined, completionTablesCache.value[cacheKey], catalog);
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
return completionTablesCache.value[cacheKey];
}
const querySchema = catalog ? "" : connectionDatabaseMetadataSchema(getConfig(connectionId), 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);
}
if (requestRevision !== completionCacheRevision(connectionId, database)) return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog, options);
completionTablesCache.value[cacheKey] = tableInfosToCompletionTables(tables, undefined, catalog);
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<string, number>();
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.
const detail = existing.detail ?? table.detail;
deduped[existingIndex] = {
...table,
...existing,
...(detail ? { detail } : {}),
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"],
caseSensitive = false,
): Promise<SqlCompletionObject[]> {
const normalizedFilter = filter.trim().toLowerCase();
const cacheFilter = caseSensitive ? filter.trim() : normalizedFilter;
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 ?? ""}:${cacheFilter}:${limit ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}:${[...objectKinds].sort().join(",")}:${caseSensitive ? "case-sensitive" : "case-insensitive"}`
: `${connectionId}:${database}:${schema ?? ""}`;
if (!completionObjectsCache.value[cacheKey]) {
await withCompletionInFlight(
`${cacheKey}:objects`,
async () => {
await ensureConnected(connectionId);
if (filteredRoutineAssistant) {
try {
let assistantObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive);
if (shouldWidenCompletionMatch(filter, assistantObjects.length, limit)) {
try {
const widenedObjects = await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds, caseSensitive, "contains");
assistantObjects = [...assistantObjects, ...widenedObjects];
} catch {
// Keep the prefix matches when the widened lookup is unavailable.
}
}
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(assistantObjects);
} catch {
if (objectKinds.length === 1 && objectKinds[0] === "sequence") {
completionObjectsCache.value[cacheKey] = [];
} else {
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, connectionDatabaseMetadataSchema(getConfig(connectionId), database, schema));
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, connectionDatabaseMetadataSchema(getConfig(connectionId), database, schema));
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<ObjectInfo[]> {
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<string>();
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<SqlCompletionColumn[]> {
const config = getConfig(connectionId);
// Use the effective database type (e.g. a JDBC connection whose URL is
// `jdbc:oracle:...` resolves to "oracle") rather than the raw db_type.
// Otherwise a JDBC-Oracle connection with no schema selected falls
// through neither the Oracle current-schema completion path nor the
// schema-required early return below finds a schema, and every star
// expansion silently returns no columns.
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
const oracleIdentifier = effectiveDbType === "oracle" || effectiveDbType === "oceanbase-oracle";
const uppercaseUnquotedIdentifier = oracleIdentifier || effectiveDbType === "saphana";
const completionTable = uppercaseUnquotedIdentifier && context?.tableQuoted === false ? table.toUpperCase() : table;
const rawCompletionSchema = schema?.trim() || (effectiveDbType === "dameng" ? config?.username?.trim() || undefined : undefined);
const completionSchema = uppercaseUnquotedIdentifier && rawCompletionSchema && context?.schemaQuoted === false ? rawCompletionSchema.toUpperCase() : rawCompletionSchema;
const usesCurrentSchema = usesOracleCurrentSchemaCompletion(effectiveDbType, completionSchema);
if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !completionSchema && !usesCurrentSchema) {
return [];
}
const sessionCacheScope = usesCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : "";
const cacheKey = `${completionColumnsKey(connectionId, database, completionTable, completionSchema, catalog, context)}${sessionCacheScope}`;
if (!completionColumnsCache.value[cacheKey]) {
const requestRevision = completionCacheRevision(connectionId, database);
await withCompletionInFlight(
`${cacheKey}:columns`,
async () => {
await ensureConnected(connectionId);
// Use assistant metadata opportunistically, then fall back to canonical metadata.
if (!usesCurrentSchema && !catalog) {
try {
const assistantColumns = await listCompletionAssistantColumns(connectionId, database, completionTable, completionSchema, context, requestRevision);
if (assistantColumns.length > 0) {
const columns = 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,
}));
if (requestRevision !== completionCacheRevision(connectionId, database)) return;
completionColumnsCache.value[cacheKey] = columns;
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
return;
}
} catch {
// Fall back to the existing metadata path below.
}
}
const querySchema = usesCurrentSchema ? "" : metadataQuerySchema(connectionId, database, completionSchema);
const columns = await api.getColumns(connectionId, database, querySchema, completionTable, catalog, usesCurrentSchema ? context?.clientSessionId : undefined);
if (requestRevision !== completionCacheRevision(connectionId, database)) return;
completionColumnsCache.value[cacheKey] = columns;
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
},
{ scope: completionLimiterScope(connectionId, database), kind: "columns" },
);
}
if (!completionColumnsCache.value[cacheKey]) {
return listCompletionColumns(connectionId, database, table, schema, context, catalog);
}
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 (!usesCurrentSchema) indexCompletionColumns(connectionId, database, completionTable, completionSchema, columns, catalog, context);
return columns;
}
async function listCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise<SqlCompletionForeignKey[]> {
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]) {
const requestRevision = completionCacheRevision(connectionId, database);
await withCompletionInFlight(
`${cacheKey}:fkeys`,
async () => {
await ensureConnected(connectionId);
const querySchema = metadataQuerySchema(connectionId, database, schema);
const foreignKeys = await api.listForeignKeys(connectionId, database, querySchema, table);
if (requestRevision !== completionCacheRevision(connectionId, database)) return;
completionForeignKeysCache.value[cacheKey] = foreignKeys;
evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX);
},
{ scope: completionLimiterScope(connectionId, database), kind: "foreignKeys" },
);
}
if (!completionForeignKeysCache.value[cacheKey]) return listCompletionForeignKeys(connectionId, database, table, schema);
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<SqlCompletionTable[]> {
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<SqlCompletionObject[]> {
return listCompletionObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema);
}
function refreshCompletionSchemas(connectionId: string, database: string): Promise<string[]> {
return listCompletionSchemas(connectionId, database);
}
function refreshCompletionDatabases(connectionId: string): Promise<string[]> {
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<SqlCompletionColumn[]> {
return listCompletionColumns(connectionId, database, table, schema, context, catalog);
}
function refreshCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise<SqlCompletionForeignKey[]> {
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;
await settingsStore.updateEditorSettingsAndPersist({
connectTimeoutInheritConnectionIds: connectIds,
queryTimeoutInheritConnectionIds: queryIds,
});
}
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<string, number> = {};
const querySnapshots: Record<string, number> = {};
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 persistConnectionDeletion(nextConnections: ConnectionConfig[], nextLayout: SidebarLayout) {
const previousConnections = connections.value;
const previousLayout = sidebarLayout.value;
const previousConnectTimeoutIds = [...settingsStore.editorSettings.connectTimeoutInheritConnectionIds];
const previousQueryTimeoutIds = [...settingsStore.editorSettings.queryTimeoutInheritConnectionIds];
const nextConnectTimeoutIds = previousConnectTimeoutIds.filter((id) => nextConnections.some((connection) => connection.id === id));
const nextQueryTimeoutIds = previousQueryTimeoutIds.filter((id) => nextConnections.some((connection) => connection.id === id));
const connectionsChanged = nextConnections.length !== previousConnections.length || nextConnections.some((connection, index) => connection !== previousConnections[index]);
const timeoutSettingsChanged = !sameIds(nextConnectTimeoutIds, previousConnectTimeoutIds) || !sameIds(nextQueryTimeoutIds, previousQueryTimeoutIds);
const layoutChanged = nextLayout !== previousLayout;
let connectionsPersisted = false;
let timeoutSettingsPersisted = false;
let layoutSaveStarted = false;
try {
if (connectionsChanged) {
await persistConnections(nextConnections);
connectionsPersisted = true;
}
if (timeoutSettingsChanged) {
await persistTimeoutInheritanceIds(nextConnectTimeoutIds, nextQueryTimeoutIds);
timeoutSettingsPersisted = true;
}
if (layoutChanged) {
layoutSaveStarted = true;
await api.saveSidebarLayout(nextLayout);
}
} catch (error) {
const rollbackErrors: unknown[] = [];
if (layoutSaveStarted) {
try {
await api.saveSidebarLayout(previousLayout);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (timeoutSettingsPersisted) {
try {
await persistTimeoutInheritanceIds(previousConnectTimeoutIds, previousQueryTimeoutIds);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (connectionsPersisted) {
try {
await persistConnections(previousConnections);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length) {
const details = rollbackErrors.map((rollbackError) => (rollbackError instanceof Error ? rollbackError.message : String(rollbackError))).join("; ");
const recoveryError = new Error(`Connection deletion failed and recovery was incomplete: ${details}`);
(recoveryError as Error & { cause?: unknown }).cause = error;
throw recoveryError;
}
throw error;
}
}
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);
// Recover the global timeout from the localStorage backup only when the
// settings blob on disk never carried one — the downgrade case, where an
// older build predated the setting. When the user's persisted value exists it
// is authoritative and must win over a backup that can lag behind it (the
// upgrade case, where a stale backup otherwise reset a saved timeout).
const recoverGlobalConnectFromBackup = migrationVersion < 2 && !!backup && !settingsStore.hasPersistedGlobalTimeout("connect");
const recoverGlobalQueryFromBackup = migrationVersion < 2 && !!backup && !settingsStore.hasPersistedGlobalTimeout("query");
const globalConnectTimeoutSecs = recoverGlobalConnectFromBackup ? backup!.globalConnectTimeoutSecs : settingsStore.editorSettings.globalConnectTimeoutSecs;
const globalQueryTimeoutSecs = recoverGlobalQueryFromBackup ? 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);
}
/** 表分组的 scope 一律从「解析到的容器」派生:simple 显示模式下表行带 schema
* 而容器不带,直接用行字段算 key 会把读写劈成两份。入参可以是树行,也可以是
* 只带身份字段的纯 scope 值(action 参数),后者按字段匹配容器。 */
function resolveTableVGroupScope(node: TreeNode | TableVGroupScope): { scope: TableVGroupScope; scopeKey: string } | null {
const scope = resolveTableVGroupScopeFromNode(treeNodes.value, node);
const scopeKey = tableVGroupScopeKey(scope);
return scopeKey ? { scope, scopeKey } : null;
}
/** 立即落盘全部待写布局(清防抖计时器与脏集合):initFromDisk 等整体覆盖内存布局的
* 路径必须先调用,否则窗口内 ≤300ms 的分组编辑会被旧快照静默回滚。 */
async function flushTableVGroupPersist(): Promise<void> {
if (tableVGroupPersistTimer) {
clearTimeout(tableVGroupPersistTimer);
tableVGroupPersistTimer = null;
}
if (!dirtyTableVGroupScopeKeys.size) return;
const pending = [...dirtyTableVGroupScopeKeys];
dirtyTableVGroupScopeKeys.clear();
await Promise.all(
pending.map((key) => {
const layout = tableVGroupLayouts.value[key];
return layout ? api.saveTableVGroups(key, layout).catch(() => {}) : Promise.resolve();
}),
);
}
function scheduleTableVGroupPersistFlush() {
if (tableVGroupPersistTimer) clearTimeout(tableVGroupPersistTimer);
tableVGroupPersistTimer = setTimeout(() => {
tableVGroupPersistTimer = null;
for (const key of dirtyTableVGroupScopeKeys) {
const layout = tableVGroupLayouts.value[key];
if (layout) api.saveTableVGroups(key, layout).catch(() => {});
}
dirtyTableVGroupScopeKeys.clear();
}, 300);
}
/** 分组节点是显示层投影:按已解析的作用域重建容器子节点,容器里不留投影副本。 */
function reprojectTableVGroupScope(scope: TableVGroupScope, scopeKey: string, tableName?: string, rowType?: string) {
const container = findTableVGroupContainerNode(treeNodes.value, scope, tableName, rowType);
if (!container?.children) return;
container.children = applyTableVGroupsToChildren(stripTableVGroupsFromChildren(container.children), tableVGroupLayouts.value[scopeKey], scope);
}
/** 分组成员回收:只有「本 scope 的完整列表」才能判定成员存亡。
* 同一连接下并存多种列表:各分组容器(tables/views/procedures/triggers 各自的
* 完整列表)、非分组容器列表、以及分页中间态。非本容器类别的列表或分页结果作为
* 依据会把有效成员误删并持久化,故列表语义由调用点声明(`completeTableList`),
* 本函数复核容器类别与分页态,回收范围限定容器自身类别(objectType)。 */
function pruneTableVGroupStaleMembers(parent: TreeNode, children: TreeNode[], completeTableList: boolean) {
const kind = tableVGroupKindOfContainerNode(parent);
if (!kind || !completeTableList) return;
const resolved = resolveTableVGroupScope(parent);
const layout = resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined;
if (!resolved || !hasTableVGroupEntries(layout)) return;
if (hasTableTreeLoadMore(children)) return;
const keepNames = collectTableTreeNames(stripTableVGroupsFromChildren(children), kind);
const next = pruneTableVGroupMembersOp(layout, keepNames);
if (next === layout) return;
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, next);
}
/** 写入某作用域的布局:即时重投影 + 合并 300ms 后落盘。作用域须已解析出 scopeKey。 */
function updateTableVGroupLayout(scope: TableVGroupScope, scopeKey: string, nextLayout: TableVGroupLayout, tableName?: string, rowType?: string) {
tableVGroupLayouts.value = { ...tableVGroupLayouts.value, [scopeKey]: nextLayout };
dirtyTableVGroupScopeKeys.add(scopeKey);
scheduleTableVGroupPersistFlush();
reprojectTableVGroupScope(scope, scopeKey, tableName, rowType);
}
/** 分组变更的统一入口:读当前布局 → 变换 → 写回。作用域不可解析或尚无布局时跳过。 */
function updateTableVGroupLayoutFor(scope: TableVGroupScope, transform: (layout: TableVGroupLayout) => TableVGroupLayout, tableName?: string, rowType?: string) {
const resolved = resolveTableVGroupScope(scope);
if (!resolved) return;
const current = tableVGroupLayouts.value[resolved.scopeKey];
if (!current) return;
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, transform(current), tableName, rowType);
}
function rebuildTreeNodes() {
const existingNodesMap = new Map<string, TreeNode>();
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,
comment: node.comment,
searchAliases: node.searchAliases,
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();
}
async function removeConnectionGroups(groupIds: Iterable<string>, deleteConnections = false): Promise<string[]> {
const uniqueGroupIds = [...new Set(groupIds)];
const previousLayout = sidebarLayout.value;
const connectionIds = deleteConnections ? connectionIdsInGroupsOp(sidebarLayout.value, uniqueGroupIds).filter((id) => connections.value.some((connection) => connection.id === id)) : [];
const oneTimeIds = connectionIds.filter((id) => getConfig(id)?.one_time === true);
const removedConnectionIds = new Set(connectionIds);
// 删除策略需要连接配置,applyConnectionRemoval 之后就读不到了,先抓快照。
const removedConnectionConfigs = connectionIds.map((id) => getConfig(id)).filter((config): config is ConnectionConfig => !!config);
const nextConnections = removedConnectionIds.size ? connections.value.filter((connection) => !removedConnectionIds.has(connection.id)) : connections.value;
let layoutAfterConnectionRemoval = previousLayout;
for (const id of removedConnectionIds) layoutAfterConnectionRemoval = removeConnectionFromSidebarLayout(layoutAfterConnectionRemoval, id);
const nextLayout = deleteGroupsOp(layoutAfterConnectionRemoval, uniqueGroupIds);
if (nextLayout === previousLayout && nextConnections === connections.value) return [];
await persistConnectionDeletion(nextConnections, nextLayout);
if (removedConnectionIds.size) {
applyConnectionRemoval(removedConnectionIds, nextConnections, nextLayout);
purgeTableVGroupsForConnections(removedConnectionIds);
// 删除已经落盘完成;页签处理失败只告警,不能让已成功的删除以异常收场。
try {
await applyDeletedConnectionTabHandling(removedConnectionConfigs);
} catch (error) {
console.warn("[DBX][connection:delete:tab-handling-failed]", { connectionIds: [...removedConnectionIds], error });
}
} else {
sidebarLayout.value = nextLayout;
rebuildTreeNodes();
}
await cleanupRemovedOneTimeConnections(oneTimeIds);
const remainingGroupIds = new Set(nextLayout.groups.map((group) => group.id));
const removedGroupIds = new Set(previousLayout.groups.filter((group) => !remainingGroupIds.has(group.id)).map((group) => group.id));
if (removedGroupIds.size) {
const nextPinnedOrder = pinnedTreeNodeOrder.value.filter((pinId) => !removedGroupIds.has(pinId));
if (nextPinnedOrder.length !== pinnedTreeNodeOrder.value.length) {
setPinnedTreeNodeOrder(nextPinnedOrder);
persistPinnedTreeNodeIds();
}
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => !removedGroupIds.has(id));
if (selectedTreeNodeId.value && removedGroupIds.has(selectedTreeNodeId.value)) selectedTreeNodeId.value = null;
if (treeSelectionAnchorId.value && removedGroupIds.has(treeSelectionAnchorId.value)) treeSelectionAnchorId.value = null;
if (!selectedTreeNodeIds.value.length) connectionMultiSelectActive.value = false;
}
return connectionIds;
}
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(protection: ConnectionExportProtection, selectedConnectionIds?: Iterable<string>) {
if (protection.mode === "encrypted" && !protection.passphrase) throw new Error("passphrase_required");
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 = snapshotConnectionsForExport(connections.value, {
connectTimeoutSecs: () => settingsStore.editorSettings.globalConnectTimeoutSecs,
queryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs,
});
const exportData = buildConnectionConfigBundle(exportedConnections, sidebarLayout.value, tunnelProfileStore.profiles, selectedConnectionIds);
const json = JSON.stringify(exportData);
let content: string;
if (protection.mode === "encrypted") {
const { encryptConfig } = await import("@/lib/backend/configCrypto");
const payload = await encryptConfig(json, protection.passphrase);
content = JSON.stringify(payload, null, 2);
} else {
const scrubbedData = {
...exportData,
connections: exportData.connections.map(scrubConnectionForPlaintextExport),
tunnelProfiles: exportData.tunnelProfiles?.map(scrubTunnelProfileForPlaintextExport),
};
content = JSON.stringify(scrubbedData, 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 "cancelled" as const;
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);
}
return "saved" as const;
}
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<FileList>((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<FileList>((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<string>((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 parseConnectionsImport(content: string, passphrase: string | null): Promise<ConnectionConfigBundle> {
if (!passphrase && content.trimStart().startsWith("<")) {
const { parseNavicatConnections } = await import("@/lib/imports/navicatImport");
return { connections: await parseNavicatConnections(content) };
}
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);
return { connections: result.connections, layout: result.layout };
}
if (isDbeaverImportPayload(content)) {
const result = await parseDbeaverImport(content);
return { connections: result.connections, layout: result.layout };
}
return parseConnectionConfigObject(JSON.parse(content));
}
const { decryptConfig } = await import("@/lib/backend/configCrypto");
const json = await decryptConfig(JSON.parse(content), passphrase);
return parseConnectionConfigObject(JSON.parse(json));
}
async function applyConnectionsImport(preview: ConnectionConfigBundle, selectedConnectionIds?: Iterable<string>): Promise<{ count: number; layout?: SidebarLayout }> {
const selected = selectConnectionConfigBundle(preview, selectedConnectionIds);
const importedTunnelProfileStore = useTunnelProfileStore();
await importedTunnelProfileStore.init();
const imported = prepareConnectionConfigImport(
selected,
connections.value,
importedTunnelProfileStore.profiles.map((profile) => profile.id),
uuid,
);
if (imported.tunnelProfiles?.length) {
await importedTunnelProfileStore.saveProfiles([...importedTunnelProfileStore.profiles, ...imported.tunnelProfiles]);
}
for (const connection of imported.connections) await addConnection(normalizeConnection(connection));
return { count: imported.connections.length, layout: imported.layout };
}
async function importConnectionsFromFile(content: string, passphrase: string | null, selectedConnectionIds?: Iterable<string>): Promise<{ count: number; layout?: SidebarLayout }> {
const preview = await parseConnectionsImport(content, passphrase);
return applyConnectionsImport(preview, selectedConnectionIds);
}
/** Read macOS Keychain passwords for DataGrip connections and update them. */
async function applyDataGripKeychainPasswords(): Promise<number> {
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<string, string>();
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<string, string>();
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),
mergeSidebarLayout(sidebarLayout.value, 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 () => {
// 整体覆盖内存布局前,先落盘窗口内未写盘的分组编辑(防止 300ms 防抖窗口
// 内的编辑被备份轮询/重载带回的旧快照静默回滚)。
await flushTableVGroupPersist();
const [pinnedOrder, saved, , loadedTableVGroups] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init(), api.loadTableVGroups()]);
setPinnedTreeNodeOrder(pinnedOrder);
await migrateTimeoutInheritance(saved);
const loadedConnections = saved.map(normalizeConnection);
const loadedIds = new Set(loadedConnections.map((connection) => connection.id));
// One-time deep-link connections are intentionally filtered from disk
// persistence. Keep them in the live list when an unrelated disk reload
// (for example, the scheduled-backup poll) completes while they are open.
const runtimeOneTimeConnections = connections.value.filter((connection) => connection.one_time === true && !loadedIds.has(connection.id));
connections.value = [...loadedConnections, ...runtimeOneTimeConnections];
if (loadedConnections.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();
// 旧版本写下的行可能缺字段(如 version),一律归一化后再进内存,避免脏数据被原样写回。
tableVGroupLayouts.value = Object.fromEntries(Object.entries(loadedTableVGroups ?? {}).map(([scopeKey, layout]) => [scopeKey, normalizeTableVGroupLayoutOp(layout)]));
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,
refreshConnectionTreeNode,
refreshDatabaseTreeNode,
refreshObjectListTreeNode,
connectedIds,
connectingIds,
connectionErrors,
setConnectionError,
clearConnectionError,
recordConnectionError,
markConnectionLost,
recordConnectionLostError,
sidebarLayout,
connectionGroupPaths,
connectionGroupOptions,
selectedConnectionGroupId,
getConfig,
getEtcdAccessCapabilities,
ensureEtcdAccessCapabilities,
canWriteEtcdKey,
connectionIdentifierQuote,
databaseCompatibilityMode,
ensureDatabaseCompatibilityMode,
databaseCompatibilityModes,
isTreeNodePinned,
orderByPinnedTreeNodes,
toggleTreeNodePin,
beginPinnedTreeNodeReorder,
endPinnedTreeNodeReorder,
isPinnedTreeNodeReorderTarget,
canReorderPinnedTreeNodes,
reorderPinnedTreeNodes,
addConnection,
copyConnectionsToTreeClipboard,
pasteConnectionClipboard,
addEphemeralConnection,
updateConnection,
updateRedisKeyGrouping,
renameConnection,
applyGlobalTimeouts,
updateConnectionDatabaseInfo,
setDefaultDatabase,
clearDefaultDatabase,
isDefaultDatabase,
setDefaultSchema,
clearDefaultSchema,
isDefaultSchema,
getRedisDatabaseAlias,
setRedisDatabaseAlias,
setVisibleDatabases,
setVisibleDatabaseFilter,
clearVisibleDatabases,
ensureVisibleDatabase,
setVisibleSchemas,
clearVisibleSchemas,
recordPrimaryVisibleObjectNames,
getSidebarVisibleFilterSummary,
removeConnection,
removeConnections,
editingConnectionId,
newConnectionGroupId,
startEditing,
stopEditing,
startCreatingConnectionInGroup,
stopCreatingConnectionInGroup,
connect,
repushPluginConnection,
reopenPluginConnection,
cancelConnecting,
disconnect,
hasDisconnectInFlight,
markConnectionOffline,
metadataGenerationFor,
disconnectAndForgetConnectionPassword,
hasSessionCredential,
closeDatabaseConnection,
ensureConnected,
warmConnection,
loadConnectedConnectionRootForSidebarSearch,
isTreeNodeChildrenLoaded,
canUseLoadedTreeNodeToggle,
releaseCollapsedTreeNodeChildren,
discardFilteredTreeNodeChildren,
restoreFilteredObjectGroupChildren,
cancelTreeNodeLoad,
setBeforeConnectHandler,
initFromDisk,
loadDatabases,
loadSidebarDatabaseStorage,
loadSidebarTableStorage,
loadRedisDatabases,
refreshRedisDbKeyCounts,
loadEtcdRoot,
loadZooKeeperRoot,
loadConsulRoot,
loadMqTenants,
loadMqttTopics,
loadNacosNamespaces,
updateRedisDbKeyStats,
loadMongoDatabases,
loadDynamoDbTables,
loadMilvusDatabases,
openElasticsearchConnectionTree,
loadElasticsearchIndices,
loadVectorCollections,
loadMongoCollections,
loadSchemas,
loadSqlServerDatabaseObjects,
loadSqlServerLinkedServers,
loadSqlServerLinkedServerCatalogs,
loadSqlServerLinkedServerSchemas,
loadDorisCatalogDatabases,
loadDorisCatalogTables,
loadTables,
loadTableForLocate,
loadObjectGroupChildren,
loadCustomTypeChildren,
loadPackageMembers,
loadXuguTablespaces,
loadXuguTypeMembers,
loadMoreObjectGroupChildren,
loadAllObjectGroupChildren,
loadTableGroups,
listOracleDatabaseLinks,
refreshOracleDatabaseLinks,
loadTreeNodeChildren,
loadColumns,
loadIndexes,
loadForeignKeys,
loadTriggers,
loadConstraints,
loadPartitions,
loadSubpartitions,
listCompletionTables,
listCompletionObjects,
listCompletionColumns,
listCompletionColumnsByPrefix,
listCompletionForeignKeys,
listCompletionSchemas,
listCompletionDatabases,
getSqlServerCompletionContext,
lookupLocalCompletionTables,
lookupLocalCompletionObjects,
lookupLocalCompletionColumns,
lookupLocalCompletionColumnsByPrefix,
lookupLocalCompletionForeignKeys,
lookupLocalCompletionSchemas,
lookupLocalCompletionDatabases,
refreshCompletionTables,
refreshCompletionObjects,
refreshCompletionColumns,
refreshCompletionForeignKeys,
refreshCompletionSchemas,
refreshCompletionDatabases,
listElasticsearchCompletionIndices,
listElasticsearchCompletionFields,
listRedisCompletionKeys,
listRedisCompletionCommandDocs,
listMongoCompletionCollections,
listMongoCompletionFields,
invalidateCompletionCache,
invalidateCompletionTableCache,
completionCacheRevision,
invalidateMetadataCache,
exportConnectionsToFile,
readImportFile,
parseConnectionsImport,
applyConnectionsImport,
importConnectionsFromFile,
applyDataGripKeychainPasswords,
applySidebarLayout,
transferSource,
schemaDiffSource,
dataCompareSource,
sqlFileSource,
diagramSource,
docsSource,
tableImportSource,
mongoDatabaseDumpSource,
mongoImportSource,
mongoImportCompleted,
tableDataGenerateSource,
fieldLineageSource,
databaseSearchSource,
databaseExportSource,
sidebarSearchQuery,
sidebarTableSearchQueries,
sidebarTableSearchIndexInvalidation,
sidebarTableNameFilters,
tableNameFilterScopeKey,
tableNameFilterForScope,
setSidebarTableNameFilter,
refreshTreeNodeForTableNameFilter,
setSidebarTableSearchQuery,
refreshSidebarTableSearch,
loadSidebarTableSearchIndex,
loadSidebarTableSearchIndexScopes,
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));
},
async deleteConnectionGroup(groupId: string) {
await removeConnectionGroups([groupId]);
},
async deleteConnectionGroups(groupIds: Iterable<string>, deleteConnections = false) {
return await removeConnectionGroups(groupIds, deleteConnections);
},
connectionIdsInGroups(groupIds: Iterable<string>) {
return connectionIdsInGroupsOp(sidebarLayout.value, groupIds);
},
toggleConnectionGroupCollapsed(groupId: string) {
updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId));
},
expandConnectionGroups(groupIds: Iterable<string>) {
updateLayoutAndRebuild(expandGroupsOp(sidebarLayout.value, groupIds));
},
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, options?: ReorderEntriesOptions) {
const layout = reorderEntriesOp(sidebarLayout.value, draggedIds, targetId, position, options);
if (layout !== sidebarLayout.value) updateLayoutAndRebuild(layout);
},
tableVGroupLayouts,
resolveTableVGroupScope,
tableVGroupLayoutFor(scope: TableVGroupScope) {
const resolved = resolveTableVGroupScope(scope);
return resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined;
},
createTableVGroup(scope: TableVGroupScope, name: string, parentGroupId?: string | null) {
const resolved = resolveTableVGroupScope(scope);
if (!resolved) return null;
const result = createTableVGroupOp(tableVGroupLayouts.value[resolved.scopeKey] ?? emptyTableVGroupLayout(), name, parentGroupId);
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, result.layout);
return result.groupId;
},
renameTableVGroup(scope: TableVGroupScope, groupId: string, name: string) {
updateTableVGroupLayoutFor(scope, (layout) => renameTableVGroupOp(layout, groupId, name));
},
deleteTableVGroups(scope: TableVGroupScope, groupIds: Iterable<string>) {
updateTableVGroupLayoutFor(scope, (layout) => deleteTableVGroupsOp(layout, groupIds));
},
moveTableToVGroup(scope: TableVGroupScope, tableName: string, groupId: string | null, rowType?: string) {
updateTableVGroupLayoutFor(scope, (layout) => moveTableToVGroupOp(layout, tableName, groupId, rowType), tableName, rowType);
},
reorderTableVGroupEntry(scope: TableVGroupScope, draggedEntryId: string, targetEntryId: string, position: TableVGroupDropPosition) {
updateTableVGroupLayoutFor(scope, (layout) => reorderTableVGroupEntryOp(layout, draggedEntryId, targetEntryId, position));
},
toggleTableVGroupCollapsed(scope: TableVGroupScope, groupId: string) {
updateTableVGroupLayoutFor(scope, (layout) => toggleTableVGroupCollapsedOp(layout, groupId));
},
setTableVGroupsEnabled(scope: TableVGroupScope, enabled: boolean) {
updateTableVGroupLayoutFor(scope, (layout) => setTableVGroupsEnabledOp(layout, enabled));
},
tableVGroupPathForTable(scope: TableVGroupScope, tableName: string, rowType?: string) {
const resolved = resolveTableVGroupScope(scope);
return tableVGroupPathForTableOp(resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined, tableName, rowType);
},
};
});