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

5484 lines
227 KiB
TypeScript

import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref, computed, watch, markRaw } from "vue";
import type {
ColumnInfo,
CompletionAssistantCandidate,
CompletionAssistantObjectKind,
CompletionAssistantRequest,
ConnectionConfig,
DatabaseConnectionInfo,
CatalogInfo,
ForeignKeyInfo,
ObjectInfo,
SchemaInfo,
SidebarLayout,
TableInfo,
TreeNode,
TunnelProfile,
VectorCollectionMeta,
} from "@/types/database";
import { inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
import {
reconcileLayout,
buildTreeNodesFromLayout,
emptyLayout,
appendConnectionToLayout,
removeConnectionFromSidebarLayout,
findConnectionLocation,
createGroup as createGroupOp,
renameGroup as renameGroupOp,
deleteGroup as deleteGroupOp,
toggleGroupCollapsed as toggleGroupCollapsedOp,
collapseAllGroups as collapseAllGroupsOp,
moveConnectionToGroup as moveConnectionToGroupOp,
remapSidebarLayoutConnectionIds,
reorderEntry as reorderEntryOp,
buildConnectionGroupPathMap,
type DropPosition,
} from "@/lib/sidebar/sidebarLayout";
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree";
import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse";
import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget";
import { shouldMarkDisconnected } from "@/lib/connection/connectionHealth";
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout";
import { requiresSqlServerLegacyCompatibilityComponent, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
import { deleteTabResultSnapshotsForOwner } from "@/lib/tabs/tabResultCache";
import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForConnection, filterSchemaNamesForConnection, filterVisibleDatabaseNames, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases";
import {
buildObjectGroupPlaceholderNodes,
buildGroupedObjectTreeNodes,
buildSimpleObjectTreeNodes,
buildTableTreeNodes,
appendTableTreeLoadMoreNode,
expandCachedObjectBrowserNodes,
filterSimpleSidebarSupplementalObjects,
mergeTableInfosIntoObjects,
mergeTableTreePageChildren,
objectGroupRefreshParentId,
objectTypesForGroupNode,
sortDatabaseObjectsByName,
tablePartitionGroups,
withoutTableTreeLoadMoreNodes,
type TableTreeLoadMoreParent,
type DatabaseObjectTreeKind,
} from "@/lib/table/tableTree";
import { hasTreeNodeDatabaseContext, normalizeCataloglessDatabaseNodes, treeNodeSchemaCachePrefix } from "@/lib/sidebar/treeNodeContext";
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/schemaTreeCache";
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
import { prunePinnedTreeNodeIdsForConnection } from "@/lib/app/pinnedTreeNodeIds";
import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { useSettingsStore } from "@/stores/settingsStore";
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator";
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import i18n from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
const CONNECTION_HEALTH_CHECK_TTL_MS = 2000;
const CONNECTION_HEALTH_CHECK_TIMEOUT_MS = 5000;
const METADATA_LOAD_MIN_TIMEOUT_MS = 15_000;
const METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS = 60_000;
const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000;
const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30;
const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000;
const METADATA_LIST_PAGE_CACHE_MAX_ENTRIES = 160;
export const COMPLETION_METADATA_CONCURRENCY = 2;
const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy";
const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)";
const SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE = "Connection attempt was superseded by a newer attempt";
function sidebarObjectGroupPageSize(): number {
const settingsStore = useSettingsStore();
const size = settingsStore.desktopSettings.sidebar_table_page_size;
return typeof size === "number" && size > 0 ? size : 500;
}
function isKafkaMqConnection(config: ConnectionConfig | undefined): boolean {
if (!config || config.db_type !== "mq") return false;
if (config.driver_profile === "kafka") return true;
return (config.external_config as Partial<MqAdminConfig> | undefined)?.systemKind === "kafka";
}
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 } | null = null;
interface TreeClipboardTableEntry {
connectionId: string;
database: string;
schema?: string;
tableName: string;
}
interface TreeClipboardConnectionEntry {
config: ConnectionConfig;
sourceGroupId: string | null;
}
export type TreeClipboard =
| {
kind: "table-copy";
tables: TreeClipboardTableEntry[];
}
| {
kind: "connection-copy";
connections: TreeClipboardConnectionEntry[];
};
interface LoadTreeOptions {
force?: boolean;
expectedSidebarSearchQuery?: string;
searchFilter?: string;
sidebarTableSearchParentId?: string;
expectedSidebarTableSearchQuery?: string;
}
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";
function redisDbLabel(db: number, _loadedKeyCount?: number, totalKeyCount?: number): string {
if (totalKeyCount == null) return `db${db}`;
return `db${db} (${totalKeyCount})`;
}
function metadataDriverProfile(config?: ConnectionConfig): string | undefined {
return config?.driver_profile || config?.db_type;
}
export const useConnectionStore = defineStore("connection", () => {
const settingsStore = useSettingsStore();
const connections = ref<ConnectionConfig[]>([]);
const isDesktop = isTauriRuntime();
const activeConnectionId = ref<string | null>(localStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY));
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);
const connectionMultiSelectActive = ref(false);
const treeClipboard = ref<TreeClipboard | null>(null);
watch(activeConnectionId, (id) => {
if (id) localStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, id);
else localStorage.removeItem(ACTIVE_CONNECTION_STORAGE_KEY);
});
const treeNodes = ref<TreeNode[]>([]);
const pinnedTreeNodeIds = ref<Set<string>>(new Set());
const connectedIds = ref<Set<string>>(new Set());
const identifierQuotes = ref<Record<string, string>>({});
const lastConnectionHealthCheckAt = ref<Record<string, number>>({});
const agentDrivers = ref<AgentDriverInstallState[]>([]);
let agentDriversRefreshPromise: Promise<void> | null = null;
const loadedTreeNodeChildrenIds = 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 elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
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>>({});
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 completionForeignKeyIndex = new Map<string, { touched: number; foreignKeys: SqlCompletionForeignKey[] }>();
const completionInFlight = new Map<string, Promise<unknown>>();
const completionMetadataLimiter = new MetadataTaskLimiter(COMPLETION_METADATA_CONCURRENCY, (event) => {
console.debug("[DBX][completion-metadata:limit]", event);
});
const transferSource = ref<{ connectionId: string; database: string } | null>(null);
const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null);
const dataCompareSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName?: string;
} | null>(null);
const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string } | null>(null);
const diagramSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName?: string;
} | null>(null);
const tableImportSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName?: string;
} | null>(null);
const tableDataGenerateSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName: string;
} | null>(null);
const fieldLineageSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName: string;
columnName: string;
} | null>(null);
const databaseSearchSource = ref<{
connectionId: string;
database: string;
schema?: string;
} | null>(null);
const databaseExportSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName?: string;
tableNames?: string[];
allDatabases?: boolean;
} | null>(null);
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
const connectionGroupPaths = computed(() => buildConnectionGroupPathMap(sidebarLayout.value));
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
const staleTreeRefreshIds = new Set<string>();
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>();
let nextLocalConnectionAttempt = 0;
let beforeConnectHandler: BeforeConnectHandler | null = null;
let initFromDiskPromise: Promise<void> | null = null;
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 result = await load();
metadataListPageCache.set(scope, result);
logMetadataLoadTrace(metadataTraceLogger, trace, "done", {
cacheStatus: options?.force ? "refresh" : "miss",
resultCount: result.length,
force: options?.force === true,
});
return result;
}
function startEditing(id: string) {
editingConnectionId.value = id;
}
function stopEditing() {
editingConnectionId.value = null;
}
function startCreatingConnectionInGroup(groupId: string) {
stopEditing();
newConnectionGroupId.value = groupId;
}
function stopCreatingConnectionInGroup() {
newConnectionGroupId.value = null;
}
const configById = computed(() => new Map(connections.value.map((c) => [c.id, c])));
function getConfig(connectionId: string) {
return configById.value.get(connectionId);
}
function connectionIdentifierQuote(connectionId?: string): string | undefined {
if (!connectionId) return undefined;
return identifierQuotes.value[connectionId];
}
function clearConnectionIdentifierQuote(connectionId: string) {
if (!(connectionId in identifierQuotes.value)) return;
const next = { ...identifierQuotes.value };
delete next[connectionId];
identifierQuotes.value = next;
}
async function refreshConnectionIdentifierQuote(connectionId: string, config: ConnectionConfig) {
clearConnectionIdentifierQuote(connectionId);
if (config.db_type !== "kingbase") 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 = findNode(treeNodes.value, 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);
return revision;
}
function isCurrentConnectionStateRevision(connectionId: string, revision: number): boolean {
return connectionStateRevisions.get(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);
}
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);
}
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;
}
function agentDriverUpdateHint(): string {
return i18n.global.t("connection.agentDriverUpdateConnectionHint");
}
function connectionErrorWithDriverUpdateHint(config: ConnectionConfig | undefined, message: string): string {
if (!config) return message;
message = appendConnectionErrorHints(config, message, i18n.global.t);
if (!hasAgentDriverUpdate(config.db_type, agentDrivers.value, config.driver_profile)) return message;
return appendAgentDriverUpdateHint(message, agentDriverUpdateHint());
}
function refreshAgentDriversForErrorHint(): Promise<void> {
if (agentDriversRefreshPromise) return agentDriversRefreshPromise;
agentDriversRefreshPromise = api
.listInstalledAgents()
.then((drivers) => {
agentDrivers.value = drivers;
})
.catch(() => undefined)
.finally(() => {
agentDriversRefreshPromise = null;
});
return agentDriversRefreshPromise;
}
function maybeAppendAgentDriverUpdateHint(connectionId: string, baseMessage: string) {
const config = getConfig(connectionId);
const message = connectionErrorWithDriverUpdateHint(config, baseMessage);
if (message !== baseMessage) {
setConnectionError(connectionId, message);
return;
}
void refreshAgentDriversForErrorHint().then(() => {
if (connectionErrors.value[connectionId] !== baseMessage) return;
const refreshedMessage = connectionErrorWithDriverUpdateHint(config, baseMessage);
if (refreshedMessage !== baseMessage) setConnectionError(connectionId, refreshedMessage);
});
}
function clearConnectionError(connectionId: string) {
if (!connectionErrors.value[connectionId]) return;
delete connectionErrors.value[connectionId];
}
function markConnectionHealthChecked(connectionId: string) {
lastConnectionHealthCheckAt.value[connectionId] = Date.now();
}
function clearConnectionHealthCheck(connectionId: string) {
if (!lastConnectionHealthCheckAt.value[connectionId]) return;
delete lastConnectionHealthCheckAt.value[connectionId];
}
function hasRecentConnectionHealthCheck(connectionId: string) {
const checkedAt = lastConnectionHealthCheckAt.value[connectionId];
return typeof checkedAt === "number" && Date.now() - checkedAt < CONNECTION_HEALTH_CHECK_TTL_MS;
}
function clearConnectionNodeLoading(connectionId: string) {
const node = findNode(treeNodes.value, 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));
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return 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);
}),
]);
} 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);
clearConnectionIdentifierQuote(connectionId);
clearConnectionNodeLoading(connectionId);
clearConnectionHealthCheck(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
recordConnectionError(connectionId, error);
}
function recordConnectionLostError(connectionId: string, error: unknown): boolean {
if (shouldMarkDisconnected(error)) {
markConnectionLost(connectionId, error);
return true;
}
return false;
}
// Metadata loaders keep this internal: match connection-loss errors before recording generic errors.
function recordMetadataLoadError(connectionId: string, error: unknown) {
if (recordConnectionLostError(connectionId, error)) return;
recordConnectionError(connectionId, error);
}
async function withConnectionAttemptTimeout<T>(promise: Promise<T>, config: ConnectionConfig): Promise<T> {
const timeoutMs = connectionAttemptTimeoutMs(config);
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 {
const labelMap: Record<string, string> = {
mysql: "MySQL",
postgres: "PostgreSQL",
sqlite: "SQLite",
redis: "Redis",
etcd: "etcd",
zookeeper: "Apache ZooKeeper",
duckdb: "DuckDB",
clickhouse: "ClickHouse",
sqlserver: "SQL Server",
mongodb: "MongoDB",
oracle: "Oracle",
"mongodb-legacy": MONGO_LEGACY_DRIVER_LABEL,
elasticsearch: "Elasticsearch",
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: "KingBase",
highgo: "瀚高 HighGo",
yashandb: "崖山 YashanDB",
vastbase: "Vastbase",
goldendb: "GoldenDB",
access: "Microsoft Access",
h2: "H2",
snowflake: "Snowflake",
trino: "Trino",
prestosql: "PrestoSQL",
hive: "Hive",
spark: "Apache Spark",
db2: "DB2",
informix: "Informix",
neo4j: "Neo4j",
cassandra: "Cassandra",
bigquery: "BigQuery",
kylin: "Kylin",
sundb: "SunDB",
oscar: "神通 OSCAR",
influxdb: "InfluxDB",
};
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 === "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,
transport_layers: Array.isArray(config.transport_layers) ? config.transport_layers : [],
connect_timeout_secs: config.connect_timeout_secs || 10,
query_timeout_secs: config.query_timeout_secs ?? 30,
idle_timeout_secs: config.idle_timeout_secs ?? 60,
keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS,
database_info: normalizeDatabaseConnectionInfo(config.database_info),
};
}
function loadPinnedTreeNodeIdsFromLocalStorage(): Set<string> {
try {
if (typeof localStorage === "undefined") return new Set();
const saved = localStorage.getItem(PINNED_TREE_NODES_STORAGE_KEY);
const ids = saved ? JSON.parse(saved) : [];
return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : []);
} catch {
return new Set();
}
}
async function loadPinnedTreeNodeIds(): Promise<Set<string>> {
if (!isDesktop) return loadPinnedTreeNodeIdsFromLocalStorage();
const ids = await api.loadPinnedTreeNodeIds().catch(() => []);
const valid = ids.filter((id) => typeof id === "string");
if (valid.length > 0) return new Set(valid);
// Migrate legacy localStorage values for existing desktop users.
const legacy = loadPinnedTreeNodeIdsFromLocalStorage();
if (legacy.size > 0) {
await api.savePinnedTreeNodeIds([...legacy]).catch(() => undefined);
if (typeof localStorage !== "undefined") {
localStorage.removeItem(PINNED_TREE_NODES_STORAGE_KEY);
}
}
return legacy;
}
function persistPinnedTreeNodeIds() {
if (isDesktop) {
void api.savePinnedTreeNodeIds([...pinnedTreeNodeIds.value]).catch(() => undefined);
return;
}
if (typeof localStorage === "undefined") return;
localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify([...pinnedTreeNodeIds.value]));
}
function isTreeNodePinned(node: TreeNode | string): boolean {
if (typeof node === "string") return pinnedTreeNodeIds.value.has(node);
return pinnedTreeNodeIds.value.has(treeNodePinKey(node)) || pinnedTreeNodeIds.value.has(node.id);
}
function isConnectionUtilityNode(node: TreeNode): boolean {
return node.type === "user-admin" || node.type === "dameng-job-admin";
}
function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] {
return (children || []).filter((child) => !isConnectionUtilityNode(child));
}
function hasConnectionMetadataChildren(children: TreeNode[] | undefined): boolean {
return connectionMetadataChildren(children).length > 0;
}
function preserveExistingConnectionMetadataChildren(parent: TreeNode, children: TreeNode[]): TreeNode[] {
if (parent.type !== "connection" || hasConnectionMetadataChildren(children)) return children;
const existingMetadataChildren = connectionMetadataChildren(parent.children);
const nextUtilityChildren = children.filter(isConnectionUtilityNode);
if (existingMetadataChildren.length === 0 || nextUtilityChildren.length === 0) return children;
return [...existingMetadataChildren, ...nextUtilityChildren];
}
// Leaf tree nodes (table columns / indexes / foreign keys / triggers) are
// immutable data payloads: they never expand, never load children, and their
// fields are never mutated after creation. A large schema can produce tens of
// thousands of them, and Vue's deep reactivity wraps every node AND its nested
// `meta` object in a Proxy — the dominant memory cost of the schema tree.
// Marking each leaf raw keeps Vue from wrapping it (and, since Vue does not
// recurse into raw objects, its `meta` too), mirroring the markRaw() treatment
// queryStore already applies to result rows. Containers stay reactive so their
// children / isExpanded / isLoading mutations still drive the UI.
const LEAF_TREE_NODE_TYPES = new Set<TreeNode["type"]>(["column", "index", "fkey", "trigger"]);
function markRawLeafTreeNodes(nodes: TreeNode[]): TreeNode[] {
for (const node of nodes) {
if (LEAF_TREE_NODE_TYPES.has(node.type)) {
markRaw(node);
} else if (node.children && node.children.length > 0) {
markRawLeafTreeNodes(node.children);
}
}
return nodes;
}
function setChildren(parent: TreeNode, children: TreeNode[]) {
children = preserveExistingConnectionMetadataChildren(parent, children);
if (parent.children && parent.children.length > 0) {
const oldMap = new Map(parent.children.map((c) => [c.id, c] as const));
children = children.map((child) => {
const old = oldMap.get(child.id);
if (old?.isLoading) {
const isExpanded = old.isExpanded;
const isLoading = old.isLoading;
const oldChildren = old.children;
Object.assign(old, child);
old.isExpanded = isExpanded;
old.isLoading = isLoading;
old.children = oldChildren;
return old;
}
if (old?.isExpanded) {
return { ...child, isExpanded: true, children: old.children };
}
return child;
});
}
const migratedPins = migrateLegacyPinnedTreeNodeIds(children, pinnedTreeNodeIds.value);
if (migratedPins.changed) {
pinnedTreeNodeIds.value = migratedPins.ids;
persistPinnedTreeNodeIds();
}
syncPinnedTreeNodeStateInPlace(children, migratedPins.ids);
parent.children = markRawLeafTreeNodes(children);
loadedTreeNodeChildrenIds.value.add(parent.id);
}
function removeTreeNode(nodeId: string) {
const parent = findParentNode(treeNodes.value, nodeId);
if (parent?.children) {
parent.children = parent.children.filter((c) => c.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 (!supportsDatabaseUserAdmin(effectiveDatabaseTypeForConnection(config))) return undefined;
const existing = existingConnectionNode?.children?.find((child) => child.type === "user-admin");
return {
id: `${connectionId}:__user_admin`,
label: "tree.userAdmin",
type: "user-admin",
connectionId,
database: "",
isExpanded: existing?.isExpanded ?? false,
};
}
function buildDamengJobAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
const config = getConfig(connectionId);
if (effectiveDatabaseTypeForConnection(config) !== "dameng") return undefined;
const existing = existingConnectionNode?.children?.find((child) => child.type === "dameng-job-admin");
return {
id: `${connectionId}:__dameng_jobs`,
label: "tree.damengJobAdmin",
type: "dameng-job-admin",
connectionId,
database: "",
isExpanded: existing?.isExpanded ?? false,
};
}
function withConnectionUtilityNodes(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
const nonUtilityChildren = connectionMetadataChildren(children);
const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode);
const damengJobAdminNode = buildDamengJobAdminNode(connectionId, existingConnectionNode);
return [...nonUtilityChildren, userAdminNode, damengJobAdminNode].filter(Boolean) as TreeNode[];
}
function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
return withConnectionUtilityNodes(connectionId, children, existingConnectionNode);
}
function schemaCacheKey(...parts: string[]): string {
return parts.map((part) => encodeURIComponent(part)).join(":");
}
function supportedSidebarObjectTypes(config?: ConnectionConfig): DatabaseObjectTreeKind[] {
const dbType = effectiveDatabaseTypeForConnection(config);
return sidebarObjectKindsForDatabase(dbType);
}
function sortSidebarSchemaInfos(schemas: readonly SchemaInfo[]): SchemaInfo[] {
const byName = new Map<string, SchemaInfo>();
for (const schema of schemas) {
const name = schema.name.trim();
if (!name) continue;
byName.set(name, { name, comment: schema.comment ?? null });
}
return sortSidebarNames([...byName.keys()]).map((name) => byName.get(name)!);
}
function objectGroupCacheKey(node: TreeNode): string {
return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, "objects-v5");
}
function metadataListDriverProfile(connectionId?: string): string | undefined {
return connectionId ? metadataDriverProfile(getConfig(connectionId)) : undefined;
}
function metadataListCacheScope(options: {
kind: string;
connectionId?: string | null;
database?: string | null;
schema?: string | null;
nodeKind?: string | null;
objectTypes?: readonly string[] | null;
searchFilter?: string | null;
limit?: number | null;
offset?: number | null;
sidebarDisplayMode?: string | null;
extra?: MetadataScopeInput["extra"];
}): MetadataScopeInput {
return {
kind: options.kind,
connectionId: options.connectionId,
database: options.database,
schema: options.schema,
nodeKind: options.nodeKind,
objectTypes: options.objectTypes,
searchFilter: options.searchFilter,
limit: options.limit,
offset: options.offset,
sidebarDisplayMode: options.sidebarDisplayMode,
driverProfile: metadataListDriverProfile(options.connectionId || undefined),
extra: options.extra,
};
}
function invalidateMetadataCaches(match: MetadataCacheInvalidation): number {
return metadataListPageCache.invalidate(match) + invalidateTableMetadataCache(match);
}
function invalidateMetadataCachesByTreePrefix(prefix: string) {
const [connectionId, database, schema, tableName] = prefix.split(":").map((part) => {
try {
return decodeURIComponent(part);
} catch {
return part;
}
});
if (!connectionId) return;
invalidateMetadataCaches({
connectionId,
database: database || undefined,
schema: schema || undefined,
tableName: tableName && !tableName.startsWith("__") ? tableName : undefined,
});
}
function invalidateMetadataCachesForNode(node: TreeNode) {
if (!node.connectionId) return;
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" ? node.label : undefined);
invalidateMetadataCaches({
connectionId: node.connectionId,
database: node.database || undefined,
schema: node.schema || undefined,
tableName,
});
}
function invalidateMetadataCache(connectionId: string, database?: string, schema?: string, tableName?: string) {
invalidateMetadataCaches({ connectionId, database, schema, tableName });
}
function buildLoadMoreNode(parent: TreeNode, offset: number, pageSize: number): TreeNode {
return {
id: `${parent.id}:__load_more:${offset}`,
label: "tree.loadMore",
type: "load-more",
connectionId: parent.connectionId,
database: parent.database,
schema: parent.schema,
isLoading: false,
loadMore: {
parentId: parent.id,
offset,
pageSize,
},
};
}
function withoutLoadMoreNodes(children: TreeNode[] | undefined): TreeNode[] {
return (children || []).filter((child) => child.type !== "load-more");
}
function objectGroupChildrenFromObjects(options: { node: TreeNode; parentNodeId: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; objects: ObjectInfo[] }): TreeNode[] {
const grouped = buildGroupedObjectTreeNodes({
nodeId: options.parentNodeId,
connectionId: options.node.connectionId || "",
database: options.node.database || "",
schema: options.effectiveSchema,
objects: options.objects.filter((object) => options.objectTypes.includes(normalizedObjectTreeKind(object.object_type))),
});
const refreshedGroup = grouped.find((group) => group.type === options.node.type);
return refreshedGroup?.children ?? [];
}
function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string): SqlCompletionTable[] {
return tables.map((table) => ({
name: table.name,
schema,
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
}
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 || "").toLowerCase()}\0${node.label.toLowerCase()}`;
}
function mergeLocatedTreeChildren(parent: TreeNode, currentChildren: TreeNode[], pageChildren: TreeNode[], connectionId: string, database: string): TreeNode[] {
const tableChildren = pageChildren.filter((child) => child.type === "table");
const nonTableChildren = pageChildren.filter((child) => child.type !== "table");
let merged = tableChildren.length ? mergeTableTreePageChildren(currentChildren, tableChildren, connectionId, database) : [...currentChildren];
const existing = new Set(merged.map(treeNodeObjectIdentity));
for (const child of nonTableChildren) {
const key = treeNodeObjectIdentity(child);
if (existing.has(key)) continue;
merged.push(child);
existing.add(key);
}
const config = parent.connectionId ? getConfig(parent.connectionId) : undefined;
return sortSidebarTreeChildrenForParent(
parent,
sortDatabaseObjectsByName(merged, (node) => node.label),
config?.db_type,
);
}
function findTreeNodes(nodes: TreeNode[], predicate: (node: TreeNode) => boolean): TreeNode[] {
const matches: TreeNode[] = [];
for (const node of nodes) {
if (predicate(node)) matches.push(node);
if (node.children) matches.push(...findTreeNodes(node.children, predicate));
const hiddenOnlyChildren = node.hiddenChildren?.filter((child) => !(node.children || []).includes(child));
if (hiddenOnlyChildren?.length) matches.push(...findTreeNodes(hiddenOnlyChildren, predicate));
}
return matches;
}
async function loadPagedTableGroupChildren(options: {
node: TreeNode;
parentNodeId: string;
querySchema: string;
effectiveSchema?: string;
objectTypes: DatabaseObjectTreeKind[];
offset: number;
pageSize: number;
searchFilter?: string;
force?: boolean;
}): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> {
if (!options.node.connectionId || !options.node.database) {
return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset };
}
const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined;
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
const fetchOffset = searchFilter ? undefined : options.offset;
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",
}),
() => api.listTables(options.node.connectionId!, options.node.database!, options.querySchema, searchFilter, fetchLimit, fetchOffset, options.objectTypes),
{ force: options.force },
);
const hasMore = searchFilter ? false : tables.length > options.pageSize;
const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables;
indexCompletionTables(options.node.connectionId, options.node.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema));
const objects = mergeTableInfosIntoObjects([], pageTables, options.effectiveSchema);
const children = objectGroupChildrenFromObjects({
node: options.node,
parentNodeId: options.parentNodeId,
effectiveSchema: options.effectiveSchema,
objectTypes: options.objectTypes,
objects,
});
const lastTable = pageTables[pageTables.length - 1];
return {
children,
objectCount: children.length,
hasMore,
nextOffset: options.offset + pageTables.length,
loadMoreParent: lastTable?.parent_name ? { schema: lastTable.parent_schema, name: lastTable.parent_name } : undefined,
};
}
async function loadPagedObjectGroupChildren(options: {
node: TreeNode;
parentNodeId: string;
querySchema: string;
effectiveSchema?: string;
objectTypes: DatabaseObjectTreeKind[];
offset: number;
pageSize: number;
searchFilter?: string;
force?: boolean;
}): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number }> {
if (!options.node.connectionId || !options.node.database) {
return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset };
}
const searchFilter = options.searchFilter || undefined;
const fetchLimit = searchFilter ? undefined : options.pageSize + 1;
const fetchOffset = searchFilter ? undefined : options.offset;
const objects = await loadCachedMetadataListPage<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,
}),
() => api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset),
{ force: options.force },
);
const hasMore = searchFilter ? false : objects.length > options.pageSize;
const pageObjects = hasMore ? objects.slice(0, options.pageSize) : objects;
const children = objectGroupChildrenFromObjects({
node: options.node,
parentNodeId: options.parentNodeId,
effectiveSchema: options.effectiveSchema,
objectTypes: options.objectTypes,
objects: pageObjects,
});
return {
children,
objectCount: children.length,
hasMore,
nextOffset: options.offset + pageObjects.length,
};
}
async function loadPagedSimpleTableChildren(options: {
nodeId: string;
connectionId: string;
database: string;
querySchema: string;
effectiveSchema?: string;
nonTableObjectTypes: DatabaseObjectTreeKind[];
offset: number;
pageSize: number;
searchFilter?: string;
force?: boolean;
}): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> {
const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined;
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
const fetchOffset = searchFilter ? undefined : options.offset;
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",
}),
() => api.listTables(options.connectionId, options.database, options.querySchema, searchFilter, fetchLimit, fetchOffset),
{ force: options.force },
);
const hasMore = searchFilter ? false : tables.length > options.pageSize;
const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables;
indexCompletionTables(options.connectionId, options.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema));
const children = buildTableTreeNodes({
nodeId: options.nodeId,
connectionId: options.connectionId,
database: options.database,
schema: options.effectiveSchema,
tables: pageTables,
});
const lastTable = pageTables[pageTables.length - 1];
return {
children,
objectCount: children.length,
hasMore,
nextOffset: options.offset + pageTables.length,
loadMoreParent: lastTable?.parent_name ? { schema: lastTable.parent_schema, name: lastTable.parent_name } : undefined,
};
}
async function loadSimpleSupplementalObjectChildren(options: { node: TreeNode; nodeId: string; connectionId: string; database: string; querySchema: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; cacheKey: string; loadOptions?: LoadTreeOptions }) {
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 supplementalChildren = buildSimpleObjectTreeNodes({
nodeId: options.nodeId,
connectionId: options.connectionId,
database: options.database,
schema: options.effectiveSchema,
objects: supplementalObjects,
});
if (supplementalChildren.length === 0) return;
if (isTreeLoadSearchChanged(searchFilter, options.loadOptions)) return;
if (!canApplyTreeMetadataResult(options.node)) return;
const loadMoreNodes = (options.node.children || []).filter((child) => child.type === "load-more");
const currentChildren = withoutLoadMoreNodes(options.node.children);
const mergedChildren = mergeLocatedTreeChildren(options.node, currentChildren, supplementalChildren, options.connectionId, options.database);
const nextChildren = [...mergedChildren, ...loadMoreNodes];
setChildren(options.node, 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;
if (staleTreeRefreshIds.has(node.id)) return;
staleTreeRefreshIds.add(node.id);
const expandedIds = collectExpandedNodeIds([node]);
clearLoadedChildrenCache(node.id);
const refreshOptions = { force: true, expectedSidebarSearchQuery: searchFilter };
void loadTreeNodeChildren(node, refreshOptions)
.then(() => {
if ((sidebarSearchQuery.value || "") !== searchFilter) return;
return restoreExpandedChildren(node, expandedIds, refreshOptions);
})
.finally(() => staleTreeRefreshIds.delete(node.id));
}
async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): 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(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 };
}
const normalizedChildren = sortSidebarTreeChildrenForParent(node, childrenWithLinkedServers, config?.db_type);
setChildren(node, node.type === "connection" && node.connectionId ? withSavedSqlRoot(node.connectionId, normalizedChildren, node) : normalizedChildren);
node.isExpanded = true;
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-hit", {
cacheStatus: decoded.isStale ? "stale" : "hit",
resultCount: normalizedChildren.length,
stale: decoded.isStale,
});
return { hit: true, isStale: decoded.isStale };
}
async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) {
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined);
}
async function savePersistedConnectionTreeChildren(cacheKey: string, children: TreeNode[]) {
const metadataChildren = connectionMetadataChildren(children);
if (metadataChildren.length === 0) return;
await savePersistedTreeChildren(cacheKey, metadataChildren);
}
function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean {
if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) 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);
setChildren(node, normalizedChildren);
}
node.isExpanded = true;
return true;
}
function isSidebarSearchQueryChanged(options?: LoadTreeOptions) {
return options?.expectedSidebarSearchQuery !== undefined && (sidebarSearchQuery.value || "") !== options.expectedSidebarSearchQuery;
}
function isSidebarTableSearchQueryChanged(options?: LoadTreeOptions) {
if (!options?.sidebarTableSearchParentId || options.expectedSidebarTableSearchQuery === undefined) return false;
return (sidebarTableSearchQueries.value[options.sidebarTableSearchParentId]?.trim() || "") !== options.expectedSidebarTableSearchQuery;
}
function activeTreeLoadSearchFilter(options?: LoadTreeOptions): string {
return (options?.searchFilter ?? sidebarSearchQuery.value) || "";
}
function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean {
if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options);
return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options);
}
function isTreeNodeChildrenLoaded(nodeId: string): boolean {
return loadedTreeNodeChildrenIds.value.has(nodeId);
}
// Collapsing a node only hides it — its loaded children stay in memory, so a
// long browsing session accumulates every schema the user ever expanded and
// the webview creeps upward. When a *large* subtree is collapsed we drop its
// children so the memory is reclaimed; re-expanding reloads them (fast, from
// the schema cache). Small subtrees are kept so routine expand/collapse stays
// instant and never triggers a reload.
const RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS = 400;
function countTreeNodeDescendants(node: TreeNode, cap: number): number {
let count = 0;
const stack: TreeNode[] = [...(node.children ?? [])];
while (stack.length) {
const current = stack.pop()!;
count += 1;
if (count >= cap) return count;
if (current.children?.length) stack.push(...current.children);
}
return count;
}
function forgetLoadedChildrenIdsForSubtree(node: TreeNode) {
loadedTreeNodeChildrenIds.value.delete(node.id);
for (const child of node.children ?? []) {
forgetLoadedChildrenIdsForSubtree(child);
}
}
// Returns true when the collapsed node's children were released. Caller should
// have already set node.isExpanded = false. Re-expanding reloads on demand
// because the node id is removed from loadedTreeNodeChildrenIds.
function releaseCollapsedTreeNodeChildren(nodeId: string): boolean {
const node = findNode(treeNodes.value, nodeId);
if (!node?.children?.length) return false;
if (countTreeNodeDescendants(node, RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) < RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) {
return false;
}
forgetLoadedChildrenIdsForSubtree(node);
node.children = [];
return true;
}
function canApplyTreeMetadataResult(node: TreeNode): boolean {
if (findNode(treeNodes.value, node.id) !== node) return false;
if (node.connectionId && !connectedIds.value.has(node.connectionId)) return false;
return true;
}
function clearLoadedChildrenCache(prefix: string) {
for (const id of loadedTreeNodeChildrenIds.value) {
if (id === prefix || id.startsWith(`${prefix}:`)) {
loadedTreeNodeChildrenIds.value.delete(id);
}
}
invalidateMetadataCachesByTreePrefix(prefix);
const rawPrefix = `${prefix}:`;
const encodedPrefix = `${schemaCacheKey(prefix)}:`;
if (rawPrefix === encodedPrefix) {
api.deleteSchemaCachePrefix(rawPrefix).catch(() => undefined);
} else {
Promise.all([api.deleteSchemaCachePrefix(rawPrefix), api.deleteSchemaCachePrefix(encodedPrefix)]).catch(() => undefined);
}
}
function schemaCachePrefixForNode(node: TreeNode): string | null {
return treeNodeSchemaCachePrefix(node);
}
async function clearPersistedTreeCacheForNode(node: TreeNode) {
const prefix = schemaCachePrefixForNode(node);
if (!prefix) return;
await api.deleteSchemaCachePrefix(prefix).catch(() => undefined);
}
function findParentNode(nodes: TreeNode[], id: string, parent: TreeNode | null = null): TreeNode | null {
for (const node of nodes) {
if (node.id === id) return parent;
if (node.children) {
const found = findParentNode(node.children, id, node);
if (found) return found;
}
}
return null;
}
function toggleTreeNodePin(node: TreeNode) {
const pinKey = treeNodePinKey(node);
const next = new Set(pinnedTreeNodeIds.value);
const wasPinned = next.has(pinKey) || next.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.
next.delete(node.id);
if (wasPinned) next.delete(pinKey);
else next.add(pinKey);
pinnedTreeNodeIds.value = next;
persistPinnedTreeNodeIds();
// Pinning is infrequent; synchronizing the loaded tree here also clears any
// stale flags created by legacy unscoped ids without rebuilding metadata.
syncPinnedTreeNodeStateInPlace(treeNodes.value, next);
}
async function addConnection(config: ConnectionConfig, targetGroupId?: string | null) {
const normalized = normalizeConnection(config);
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;
rebuildTreeNodes();
persistSidebarLayoutDebounced();
stopCreatingConnectionInGroup();
}
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 invalidateCompletionCache(connectionId: string, database?: string) {
invalidateMetadataCaches({ connectionId, database });
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(elasticsearchCompletionIndicesCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key];
}
for (const key of Object.keys(redisCompletionKeysCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key];
}
for (const key of Object.keys(mongoCompletionCollectionsCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionCollectionsCache.value[key];
}
for (const key of Object.keys(mongoCompletionFieldsCache.value)) {
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionFieldsCache.value[key];
}
for (const key of completionTableIndex.keys()) {
if (key.startsWith(cachePrefix)) completionTableIndex.delete(key);
}
for (const key of completionObjectIndex.keys()) {
if (key.startsWith(cachePrefix)) completionObjectIndex.delete(key);
}
for (const key of completionColumnIndex.keys()) {
if (key.startsWith(cachePrefix)) completionColumnIndex.delete(key);
}
for (const key of completionForeignKeyIndex.keys()) {
if (key.startsWith(cachePrefix)) completionForeignKeyIndex.delete(key);
}
for (const key of completionInFlight.keys()) {
if (key.startsWith(cachePrefix)) completionInFlight.delete(key);
}
}
async function removeConnections(ids: Iterable<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 nextConnections = connections.value.filter((c) => !removedIds.has(c.id));
await persistConnections(nextConnections);
connections.value = nextConnections;
for (const id of removedIds) {
pinnedTreeNodeIds.value = prunePinnedTreeNodeIdsForConnection(pinnedTreeNodeIds.value, id);
}
persistPinnedTreeNodeIds();
for (const id of removedIds) {
clearConnectionError(id);
connectedIds.value.delete(id);
clearConnectionIdentifierQuote(id);
clearConnectionHealthCheck(id);
sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id);
}
rebuildTreeNodes();
persistSidebarLayoutDebounced();
if (activeConnectionId.value && removedIds.has(activeConnectionId.value)) {
activeConnectionId.value = null;
}
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => !removedIds.has(id));
if (selectedTreeNodeId.value && removedIds.has(selectedTreeNodeId.value)) selectedTreeNodeId.value = null;
if (treeSelectionAnchorId.value && removedIds.has(treeSelectionAnchorId.value)) treeSelectionAnchorId.value = null;
for (const id of removedIds) {
invalidateCompletionCache(id);
clearLoadedChildrenCache(id);
void deleteTabResultSnapshotsForOwner(id);
}
}
async function removeConnection(id: string) {
await removeConnections([id]);
}
async function updateConnection(config: ConnectionConfig) {
config = normalizeConnection(config);
const idx = connections.value.findIndex((c) => c.id === config.id);
if (idx < 0) return;
const nextConnections = [...connections.value];
nextConnections[idx] = config;
await persistConnections(nextConnections);
connections.value = nextConnections;
rebuildTreeNodes();
connectedIds.value.delete(config.id);
clearConnectionIdentifierQuote(config.id);
clearConnectionHealthCheck(config.id);
invalidateCompletionCache(config.id);
clearLoadedChildrenCache(config.id);
const node = findNode(treeNodes.value, config.id);
if (node?.isExpanded) {
await reloadConnectionDatabaseChildren(config.id);
}
}
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;
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;
rebuildTreeNodes();
}
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 (!isDesktop || previousConfig.db_type !== "mongodb" || previousConfig.driver_profile === MONGO_LEGACY_DRIVER_PROFILE) {
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 idx = connections.value.findIndex((connection) => connection.id === connectionId);
if (idx < 0) return;
const nextConnections = [...connections.value];
nextConnections[idx] = {
...savedConfig,
driver_label: savedConfig.driver_label || MONGO_LEGACY_DRIVER_LABEL,
};
connections.value = nextConnections;
rebuildTreeNodes();
}
async function ensureSqlServerLegacyCompatibilityComponentInstalled(config: ConnectionConfig) {
if (!requiresSqlServerLegacyCompatibilityComponent(config)) return;
if (await api.isAgentInstalled(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY)) return;
await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY);
}
async function setDefaultDatabase(connectionId: string, database: string) {
const config = getConfig(connectionId);
if (config?.db_type === "cloudflare-d1") return;
if (!config || config.database === database) return;
await updateConnection({
...config,
database,
});
}
async function clearDefaultDatabase(connectionId: string) {
const config = getConfig(connectionId);
if (config?.db_type === "cloudflare-d1") return;
if (!config || !config.database) return;
await updateConnection({
...config,
database: undefined,
});
}
function isDefaultDatabase(connectionId: string, database: string): boolean {
const config = getConfig(connectionId);
if (config?.db_type === "cloudflare-d1") return database === "main";
return config?.database === database && database !== "";
}
async function setVisibleDatabases(connectionId: string, databaseNames: string[]) {
const config = getConfig(connectionId);
if (!config) return;
await updateVisibleDatabasesConfig(connectionId, normalizeVisibleDatabaseSelection(databaseNames, databaseNames));
await reloadConnectionDatabaseChildren(connectionId);
}
async function clearVisibleDatabases(connectionId: string) {
const config = getConfig(connectionId);
if (!config || !Array.isArray(config.visible_databases)) return;
await updateVisibleDatabasesConfig(connectionId, undefined);
await reloadConnectionDatabaseChildren(connectionId);
}
async function ensureVisibleDatabase(connectionId: string, databaseName: string) {
const config = getConfig(connectionId);
if (!config) return;
const visibleDatabases = appendVisibleDatabaseSelection(config.visible_databases, databaseName);
if (visibleDatabases === config.visible_databases) return;
await updateVisibleDatabasesConfig(connectionId, visibleDatabases);
}
async function updateVisibleDatabasesConfig(connectionId: string, visibleDatabases: string[] | undefined) {
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
if (idx < 0) return;
const nextConnections = [...connections.value];
nextConnections[idx] = {
...nextConnections[idx],
visible_databases: visibleDatabases,
};
await persistConnections(nextConnections);
connections.value = nextConnections;
invalidateCompletionCache(connectionId);
rebuildTreeNodes();
}
async function setVisibleSchemas(connectionId: string, database: string, schemaNames: string[]) {
const config = getConfig(connectionId);
if (!config) return;
const key = database || "";
await updateVisibleSchemasConfig(connectionId, key, schemaNames);
await reloadSchemaChildren(connectionId, database);
}
async function clearVisibleSchemas(connectionId: string, database: string) {
const config = getConfig(connectionId);
if (!config || !config.visible_schemas) return;
const key = database || "";
await updateVisibleSchemasConfig(connectionId, key, undefined);
await reloadSchemaChildren(connectionId, database);
}
async function updateVisibleSchemasConfig(connectionId: string, database: string, schemaNames: string[] | undefined) {
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
if (idx < 0) return;
const existing = connections.value[idx].visible_schemas;
let nextSchemas: Record<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 === "mongodb") {
await loadMongoDatabases(connectionId);
} else if (config.db_type === "elasticsearch") {
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 === "nacos") {
await loadNacosNamespaces(connectionId, { force: true });
} else {
await loadDatabases(connectionId, { force: true });
}
}
async function connect(config: ConnectionConfig) {
config = normalizeConnection(config);
if (getBlockingDisconnectInFlight(config.id)) await waitForBlockingDisconnectInFlight(config.id);
const localAttempt = beginLocalConnectionAttempt(config.id);
try {
await beforeConnectHandler?.(config);
await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
ensureLocalConnectionAttemptActive(config.id, localAttempt);
const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
await syncMongoLegacyDriverFallback(id, config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
activeConnectionId.value = id;
connectedIds.value.add(id);
void refreshConnectedDatabaseInfo(id, { ...config, id });
await refreshConnectionIdentifierQuote(id, { ...config, id });
if (id !== config.id) markSuccessfulLocalConnectionAttempt(config.id, localAttempt);
markSuccessfulLocalConnectionAttempt(id, localAttempt);
markConnectionHealthChecked(id);
clearConnectionError(config.id);
if (id !== config.id) clearConnectionError(id);
const existing = findNode(treeNodes.value, id);
if (existing) {
existing.label = config.name;
existing.type = "connection";
existing.connectionId = id;
existing.children = existing.children || [];
} else {
treeNodes.value.push({
id,
label: config.name,
type: "connection",
connectionId: id,
isExpanded: false,
children: [],
});
}
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);
clearConnectionIdentifierQuote(connectionId);
clearConnectionHealthCheck(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
invalidateCompletionCache(connectionId);
await disconnectRequest;
return true;
}
async function disconnect(connectionId: string) {
const stateRevision = bumpConnectionStateRevision(connectionId);
const disconnectRequest = startDisconnectRequest(connectionId);
cancelLocalConnectionAttempt(connectionId);
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
connectedIds.value.delete(connectionId);
clearConnectionIdentifierQuote(connectionId);
forgetSuccessfulLocalConnectionAttempt(connectionId);
clearConnectionHealthCheck(connectionId);
const node = findNode(treeNodes.value, connectionId);
if (node) {
node.isLoading = false;
node.isExpanded = false;
node.children = [];
}
clearConnectionRootMetadataLoad(connectionId);
clearLoadedChildrenCache(connectionId);
if (activeConnectionId.value === connectionId) {
activeConnectionId.value = null;
}
invalidateCompletionCache(connectionId);
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
switch (settingsStore.editorSettings.disconnectTabHandlingMode) {
case "close-tabs":
queryStore.closeConnectionTabs(connectionId);
break;
case "keep-tabs-clear-results":
queryStore.releaseConnectionTabs(connectionId);
break;
case "keep-tabs-keep-results":
queryStore.rollbackConnectionTransactions(connectionId);
break;
}
await disconnectRequest;
if (isCurrentConnectionStateRevision(connectionId, stateRevision)) {
clearConnectionError(connectionId);
}
if (shouldRemoveOneTimeConnection && isCurrentConnectionStateRevision(connectionId, stateRevision)) {
await removeConnection(connectionId);
}
}
async function closeDatabaseConnection(connectionId: string, database: string) {
await api.closeDatabaseConnection(connectionId, database);
const { useQueryStore } = await import("@/stores/queryStore");
const queryStore = useQueryStore();
switch (settingsStore.editorSettings.disconnectTabHandlingMode) {
case "close-tabs":
queryStore.closeDatabaseTabs(connectionId, database);
break;
case "keep-tabs-clear-results":
queryStore.releaseDatabaseTabs(connectionId, database);
break;
case "keep-tabs-keep-results":
queryStore.rollbackDatabaseTransactions(connectionId, database);
break;
}
const node = findDatabaseTreeNode(treeNodes.value, connectionId, database);
if (node) {
node.isExpanded = false;
node.children = [];
clearLoadedChildrenCache(node.id);
}
invalidateCompletionCache(connectionId, database);
}
async function ensureConnected(connectionId: string) {
if (connectedIds.value.has(connectionId)) {
if (hasRecentConnectionHealthCheck(connectionId)) return;
// Optimistic: verify backend pool is actually healthy
try {
await withConnectionHealthTimeout(connectionId, api.checkConnectionHealth(connectionId));
markConnectionHealthChecked(connectionId);
return;
} catch {
// Backend pool is dead — remove from connectedIds and reconnect
connectedIds.value.delete(connectionId);
clearConnectionHealthCheck(connectionId);
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
}
}
let config = getConfig(connectionId);
if (!config) {
await initFromDisk();
config = getConfig(connectionId);
}
if (!config) {
const error = new Error("Connection config not found");
recordConnectionError(connectionId, error);
throw error;
}
if (getBlockingDisconnectInFlight(connectionId)) await waitForBlockingDisconnectInFlight(connectionId);
const existingConnect = connectInFlight.get(connectionId);
if (existingConnect) {
await existingConnect;
return;
}
const localAttempt = beginLocalConnectionAttempt(connectionId);
const connectPromise = (async () => {
await beforeConnectHandler?.(config);
ensureLocalConnectionAttemptActive(connectionId, localAttempt);
const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
await syncMongoLegacyDriverFallback(connectionId, config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
connectedIds.value.add(connectionId);
void refreshConnectedDatabaseInfo(connectionId, config);
await refreshConnectionIdentifierQuote(connectionId, config);
markSuccessfulLocalConnectionAttempt(connectionId, localAttempt);
markConnectionHealthChecked(connectionId);
activeConnectionId.value = connectionId;
clearConnectionError(connectionId);
})();
connectInFlight.set(connectionId, connectPromise);
try {
await connectPromise;
} catch (e) {
if (isCancelledLocalConnectionAttempt(connectionId, localAttempt)) {
clearConnectionError(connectionId);
throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE);
}
if (isCancelledConnectionAttempt(e)) {
clearConnectionError(connectionId);
throw e;
}
if (isSupersededConnectionAttempt(e) && connectedIds.value.has(connectionId)) {
clearConnectionError(connectionId);
return;
}
recordConnectionError(connectionId, e);
clearConnectionNodeLoading(connectionId);
throw e;
} finally {
if (connectInFlight.get(connectionId) === connectPromise) {
connectInFlight.delete(connectionId);
}
finishLocalConnectionAttempt(connectionId, localAttempt);
}
}
function setBeforeConnectHandler(handler: BeforeConnectHandler | null) {
beforeConnectHandler = handler;
}
async function loadDatabases(connectionId: string, options?: LoadTreeOptions) {
const configForScope = getConfig(connectionId);
return runTreeMetadataLoad(
{
kind: "connection-databases",
connectionId,
driverProfile: metadataDriverProfile(configForScope),
},
async () => {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const config = getConfig(connectionId);
if (config?.db_type === "duckdb") {
const cacheKey = schemaCacheKey(connectionId, "duckdb-root");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const [databases, schemas] = await Promise.all([withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"), withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, "main"), "schemas")]);
const children = withSavedSqlRoot(connectionId, buildDuckDbConnectionTreeNodes(connectionId, databases, schemas), node);
if (isSidebarSearchQueryChanged(options)) return;
if (!canApplyTreeMetadataResult(node)) return;
setChildren(node, children);
await savePersistedConnectionTreeChildren(cacheKey, node.children || children);
} else if (config && connectionUsesVisibleSchemaFilter(config)) {
const schemaFilterConfig = config;
const effectiveDb = schemaFilterConfig.database || "";
const cacheKey = schemaCacheKey(connectionId, effectiveDb, "schemas");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const schemas = await withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, effectiveDb, true), "schemas");
const visibleSchemas = filterSchemaNamesForConnection(schemas, schemaFilterConfig, effectiveDb || "");
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;
if (!canApplyTreeMetadataResult(node)) return;
setChildren(node, withSavedSqlRoot(connectionId, schemaNodes, node));
await savePersistedConnectionTreeChildren(cacheKey, node.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);
return null;
});
}
if (dorisCatalogs && dorisCatalogs.length > 1) {
const cacheKey = schemaCacheKey(connectionId, "doris-catalogs");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
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;
if (!canApplyTreeMetadataResult(node)) return;
setChildren(node, children);
await savePersistedConnectionTreeChildren(cacheKey, node.children || children);
} else {
const cacheKey = schemaCacheKey(connectionId, "databases-v2");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const databases = await withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases");
const visibleNames = filterDatabaseNamesForConnection(
databases.map((database) => database.name),
config,
);
const visibleNameSet = new Set(visibleNames);
const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name));
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
const databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, {
includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases),
});
if (config?.db_type === "sqlserver") {
const linkedServers = await withMetadataLoadTimeout(connectionId, api.listSqlServerLinkedServers(connectionId), "linked servers").catch(() => []);
const linkedDatabase = sqlServerLinkedRuntimeDatabase(config);
databaseNodes.push({
...sqlServerLinkedRootNode(connectionId, linkedDatabase),
children: linkedServers.map((server) => ({
id: sqlServerLinkedServerId(connectionId, server.name),
label: server.name,
type: "linked-server",
connectionId,
database: linkedDatabase,
linkedServer: server.name,
comment: [server.product, server.provider, server.data_source].filter(Boolean).join(" / ") || null,
isExpanded: false,
children: [],
})),
});
if (linkedServers.length > 0) loadedTreeNodeChildrenIds.value.add(sqlServerLinkedRootId(connectionId));
}
const children = withSavedSqlRoot(connectionId, databaseNodes, node);
if (isSidebarSearchQueryChanged(options)) return;
if (!canApplyTreeMetadataResult(node)) return;
setChildren(node, children);
await savePersistedConnectionTreeChildren(cacheKey, node.children || children);
}
}
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
},
options,
);
}
async function loadRedisDatabases(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
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);
setChildren(
node,
withSavedSqlRoot(
connectionId,
dbs
.filter((db) => visibleNameSet.has(String(db.db)))
.map((db) => ({
id: `${connectionId}:db${db.db}`,
label: redisDbLabel(db.db, 0, db.keys),
type: "redis-db" as const,
connectionId,
database: String(db.db),
loadedKeyCount: 0,
totalKeyCount: db.keys,
isExpanded: false,
children: [],
})),
node,
),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadEtcdRoot(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
setChildren(
node,
withSavedSqlRoot(
connectionId,
[
{
id: `${connectionId}:etcd`,
label: kvRootNodeLabel("etcd"),
type: "etcd-root" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
],
node,
),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadZooKeeperRoot(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
setChildren(
node,
withSavedSqlRoot(
connectionId,
[
{
id: `${connectionId}:zookeeper`,
label: kvRootNodeLabel("zookeeper"),
type: "zookeeper-root" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
],
node,
),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadMqTenants(connectionId: string, options?: LoadTreeOptions) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const config = getConfig(connectionId);
if (isKafkaMqConnection(config)) {
// Kafka has no tenant/namespace concept. Create a synthetic child
// that opens the MQ admin console directly when clicked.
setChildren(node, [
{
id: schemaCacheKey(connectionId, "mq-tenant", "_kafka"),
label: "Topics",
type: "mq-tenant" as const,
connectionId,
mqTenant: "_kafka",
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()));
setChildren(
node,
tenantNames.map((tenant) => ({
id: schemaCacheKey(connectionId, "mq-tenant", tenant),
label: tenant,
type: "mq-tenant" as const,
connectionId,
mqTenant: tenant,
})),
);
}
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadNacosNamespaces(connectionId: string, options?: LoadTreeOptions) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const namespaces = await api.nacosListNamespaces(connectionId);
const sorted = [...namespaces].sort((left, right) => {
const leftLabel = left.namespaceShowName || left.namespace || "public";
const rightLabel = right.namespaceShowName || right.namespace || "public";
return leftLabel.localeCompare(rightLabel);
});
setChildren(
node,
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,
};
}),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
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 = redisDbLabel(db, node.loadedKeyCount, 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 = findNode(treeNodes.value, 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 = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
const dbs = await withMetadataLoadTimeout(connectionId, api.mongoListDatabases(connectionId), "MongoDB databases");
const config = getConfig(connectionId);
const visibleDbs = filterDatabaseNamesForConnection(dbs, config);
setChildren(
node,
withSavedSqlRoot(
connectionId,
sortSidebarNames(visibleDbs).map((db) => ({
id: `${connectionId}:${db}`,
label: db,
type: "mongo-db" as const,
connectionId,
database: db,
isExpanded: false,
children: [],
})),
node,
),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadElasticsearchIndices(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
const indices = await withMetadataLoadTimeout(connectionId, api.elasticsearchListIndices(connectionId), "Elasticsearch indices");
setChildren(
node,
withSavedSqlRoot(
connectionId,
sortSidebarNames(indices).map((index) => ({
id: `${connectionId}:__collection:${index}`,
label: index,
type: "elasticsearch-index" as const,
connectionId,
database: "default",
isExpanded: false,
})),
node,
),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadMilvusDatabases(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
const dbs = await withMetadataLoadTimeout(connectionId, api.documentListDatabases(connectionId), "Milvus databases");
setChildren(
node,
withSavedSqlRoot(
connectionId,
sortSidebarNames(dbs).map((db) => ({
id: `${connectionId}:${db}`,
label: db,
type: "vector-database" as const,
connectionId,
database: db,
isExpanded: false,
children: [],
})),
node,
),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadVectorCollections(connectionId: string, database?: string) {
const config = getConfig(connectionId);
const isMilvus = config?.db_type === "milvus";
const effectiveDb = database || config?.database || "default";
// Milvus groups collections under a per-database node; other vector stores stay flat under the connection.
const node = isMilvus && database ? findNode(treeNodes.value, `${connectionId}:${database}`) : findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
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,
}));
setChildren(node, isMilvus && database ? collectionChildren : withSavedSqlRoot(connectionId, collectionChildren, node));
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadMongoCollections(connectionId: string, database: string) {
const nodeId = `${connectionId}:${database}`;
const node = findNode(treeNodes.value, nodeId);
if (!node) return;
node.isLoading = true;
try {
const collections = await api.mongoListCollections(connectionId, database);
const bucketNames = new Set(collections.filter((c) => c.kind === "bucket" && c.bucketName).map((c) => c.bucketName as string));
const hiddenCollectionNames = new Set([...bucketNames].flatMap((bucketName) => [`${bucketName}.files`, `${bucketName}.chunks`]));
const collectionNames = collections
.filter((c) => c.kind !== "bucket")
.map((c) => c.name)
.filter((name) => !hiddenCollectionNames.has(name));
const collectionChildren = sortSidebarNames(collectionNames).map((col) => ({
id: `${nodeId}:${col}`,
label: col,
type: "mongo-collection" as const,
connectionId,
database,
isExpanded: false,
}));
const children = [
{
id: `${nodeId}:__gridfs`,
label: i18n.global.t("tree.gridfs"),
type: "mongo-gridfs" as const,
connectionId,
database,
isExpanded: false,
},
...collectionChildren,
];
setChildren(node, children);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
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;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const cacheKey = schemaCacheKey(connectionId, database, "schemas-v2");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const schemas = sortSidebarSchemaInfos(await api.listSchemaInfos(connectionId, database));
const visibleSchemaNames = new Set(
filterSchemaNamesForConnection(
schemas.map((schema) => schema.name),
getConfig(connectionId),
database,
),
);
const children = schemas
.filter((schema) => visibleSchemaNames.has(schema.name))
.map((schema) => {
const s = schema.name;
return {
id: `${connectionId}:${database}:${s}`,
label: s,
type: "schema" as const,
connectionId,
database,
schema: s,
comment: schema.comment,
isExpanded: false,
children: [],
};
});
if (isSidebarSearchQueryChanged(options)) return;
if (!canApplyTreeMetadataResult(node)) return;
setChildren(node, children);
await savePersistedTreeChildren(cacheKey, children);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
},
options,
);
}
async function loadSqlServerDatabaseObjects(connectionId: string, database: string, options?: LoadTreeOptions) {
const nodeId = `${connectionId}:${database}`;
const node = findNode(treeNodes.value, nodeId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const cacheKey = schemaCacheKey(connectionId, database, simpleObjectDisplay ? "sqlserver-schemas-simple-v4" : "sqlserver-schemas-grouped-v4");
if (!options?.force) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const config = getConfig(connectionId);
const schemas = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), config, database);
const children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas);
if (isSidebarSearchQueryChanged(options)) return;
setChildren(node, children);
await savePersistedTreeChildren(cacheKey, children);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadSqlServerLinkedServers(connectionId: string, options?: LoadTreeOptions) {
const node = findNode(treeNodes.value, sqlServerLinkedRootId(connectionId));
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const config = getConfig(connectionId);
const database = sqlServerLinkedRuntimeDatabase(config);
const linkedServers = await api.listSqlServerLinkedServers(connectionId);
setChildren(
node,
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: [],
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadSqlServerLinkedServerCatalogs(node: TreeNode, options?: LoadTreeOptions) {
if (!node.connectionId || !node.linkedServer) return;
const connectionId = node.connectionId;
const server = node.linkedServer;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const catalogs = await api.listSqlServerLinkedServerCatalogs(connectionId, server);
const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(connectionId));
setChildren(
node,
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: [],
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadSqlServerLinkedServerSchemas(node: TreeNode, options?: LoadTreeOptions) {
if (!node.connectionId || !node.linkedServer || !node.linkedCatalog) return;
node.isLoading = true;
try {
await ensureConnected(node.connectionId);
if (useCachedChildren(node, options)) return;
const schemas = await api.listSqlServerLinkedServerSchemas(node.connectionId, node.linkedServer, node.linkedCatalog);
const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(node.connectionId));
setChildren(
node,
sortSidebarNames(schemas)
.filter((schema) => schema.trim())
.map((schema) => {
const encodedSchema = encodeSqlServerLinkedSchema({
server: node.linkedServer!,
catalog: node.linkedCatalog!,
schema,
});
return {
id: `${node.connectionId}:${database}:${encodedSchema}`,
label: schema,
type: "linked-server-schema" as const,
connectionId: node.connectionId,
database,
schema: encodedSchema,
linkedServer: node.linkedServer,
linkedCatalog: node.linkedCatalog,
linkedSchema: schema,
isExpanded: false,
children: [],
};
}),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(node.connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
// 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;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const config = getConfig(connectionId);
const databases = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogDatabases(connectionId, catalog), "databases");
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.
const visibleNames = filterDatabaseNamesForConnection(
databases.map((database) => database.name),
config,
);
const visibleNameSet = new Set(visibleNames);
const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name));
databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { includeDefaultWhenEmpty: false });
} else {
databaseNodes = sortSidebarDatabases(databases).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;
if (!canApplyTreeMetadataResult(node)) return;
setChildren(node, databaseNodes);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
// Doris / StarRocks multi-catalog: load tables under an external-catalog
// database node. External catalogs only expose tables/views, so a flat list
// (no routines/sequences/triggers) is rendered.
async function loadDorisCatalogTables(node: TreeNode, options?: LoadTreeOptions) {
if (!node.connectionId || !node.database || !node.catalog) return;
const connectionId = node.connectionId;
const { database, catalog } = node;
const configForScope = getConfig(connectionId);
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
return runTreeMetadataLoad(
{
kind: "schema-tables",
connectionId,
database,
schema: undefined,
nodeKind: "database",
objectTypes: objectTypesForScope,
searchFilter: activeTreeLoadSearchFilter(options),
limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined,
offset: 0,
sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped",
driverProfile: metadataDriverProfile(configForScope),
extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined,
},
async () => {
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const searchFilter = activeTreeLoadSearchFilter(options);
const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v4");
if (!options?.force && !searchFilter) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const pageSize = sidebarObjectGroupPageSize();
const fetchLimit = searchFilter ? pageSize : pageSize + 1;
const fetchOffset = searchFilter ? undefined : 0;
const tables = await withMetadataLoadTimeout(connectionId, api.listTables(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog), "tables");
const hasMore = searchFilter ? false : tables.length > pageSize;
const pageTables = hasMore ? tables.slice(0, pageSize) : tables;
indexCompletionTables(connectionId, database, undefined, tableInfosToCompletionTables(pageTables, undefined));
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 (!canApplyTreeMetadataResult(node)) return;
node.objectCount = children.filter((child) => child.type !== "load-more").length;
setChildren(node, children);
if (!searchFilter && !options?.sidebarTableSearchParentId) {
await savePersistedTreeChildren(cacheKey, children);
}
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
},
options,
);
}
async function loadTables(connectionId: string, database: string, schema?: string, options?: LoadTreeOptions) {
const configForScope = getConfig(connectionId);
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
return runTreeMetadataLoad(
{
kind: "schema-tables",
connectionId,
database,
schema,
nodeKind: schema ? "schema" : "database",
objectTypes: objectTypesForScope,
searchFilter: activeTreeLoadSearchFilter(options),
limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined,
offset: 0,
sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped",
driverProfile: metadataDriverProfile(configForScope),
extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined,
},
async () => {
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
const node = findNode(treeNodes.value, nodeId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v5" : "objects-grouped-v5");
const searchFilter = activeTreeLoadSearchFilter(options);
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
if (!options?.force && !searchFilter) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const config = getConfig(connectionId);
const querySchema = connectionObjectTreeQuerySchema(config, database, schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, database, schema);
const nonTableObjectTypes = simpleObjectDisplay ? supportedSidebarObjectTypes(config).filter((objectType) => objectType !== "TABLE") : [];
let children: TreeNode[];
let nextObjectCount: number | undefined;
if (simpleObjectDisplay) {
const pageSize = sidebarObjectGroupPageSize();
const page = await loadPagedSimpleTableChildren({
nodeId,
connectionId,
database,
querySchema,
effectiveSchema,
nonTableObjectTypes,
offset: 0,
pageSize,
searchFilter: searchFilter || undefined,
force: options?.force,
});
children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children;
nextObjectCount = page.objectCount;
} else {
children = buildObjectGroupPlaceholderNodes({
nodeId,
connectionId,
database,
schema: effectiveSchema,
objectTypes: supportedSidebarObjectTypes(config),
});
if (isPostgresLikeForExtensions(config?.db_type)) {
children.push({
id: `${nodeId}:__extensions`,
label: "tree.extensions",
type: "group-extensions",
connectionId,
database,
schema: effectiveSchema,
isExpanded: false,
children: [],
});
}
}
if (isTreeLoadSearchChanged(searchFilter, options)) return;
if (!canApplyTreeMetadataResult(node)) return;
if (nextObjectCount !== undefined) node.objectCount = nextObjectCount;
setChildren(node, children);
if (!searchFilter && !isSidebarTableSearch) {
await savePersistedTreeChildren(cacheKey, children);
}
node.isExpanded = true;
if (simpleObjectDisplay && !searchFilter && !isSidebarTableSearch && nonTableObjectTypes.length > 0) {
void loadSimpleSupplementalObjectChildren({
node,
nodeId,
connectionId,
database,
querySchema,
effectiveSchema,
objectTypes: nonTableObjectTypes,
cacheKey,
loadOptions: options,
});
}
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
},
options,
);
}
async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions) {
const configForScope = node.connectionId ? getConfig(node.connectionId) : undefined;
const objectTypesForScope = objectTypesForGroupNode(node.type);
const pageSizeForScope = sidebarObjectGroupPageSize();
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;
node.isLoading = true;
try {
await ensureConnected(node.connectionId);
if (useCachedChildren(node, options)) 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 isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
if (!options?.force && !searchFilter) {
const cached = await loadPersistedTreeChildren(node, cacheKey);
if (cached.hit) {
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
}
const wantsOnlyTablesOrViews = objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW");
let children: TreeNode[];
let nextObjectCount: number;
if (wantsOnlyTablesOrViews) {
const page = await loadPagedTableGroupChildren({
node,
parentNodeId,
querySchema,
effectiveSchema,
objectTypes,
offset: 0,
pageSize: sidebarObjectGroupPageSize(),
searchFilter: searchFilter || undefined,
force: options?.force,
});
children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize()), page.loadMoreParent) : page.children;
nextObjectCount = page.objectCount;
} else {
const pageSize = sidebarObjectGroupPageSize();
const page = await loadPagedObjectGroupChildren({
node,
parentNodeId,
querySchema,
effectiveSchema,
objectTypes,
offset: 0,
pageSize,
searchFilter: searchFilter || undefined,
force: options?.force,
});
children = page.hasMore && !searchFilter ? [...page.children, buildLoadMoreNode(node, page.nextOffset, pageSize)] : page.children;
nextObjectCount = page.objectCount;
}
if (isTreeLoadSearchChanged(searchFilter, options)) return;
if (!canApplyTreeMetadataResult(node)) return;
node.objectCount = nextObjectCount;
setChildren(node, children);
if (!searchFilter && !isSidebarTableSearch) {
await savePersistedTreeChildren(cacheKey, children);
}
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(node.connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
},
options,
);
}
async function loadExtensions(connectionId: string, database: string, schema: string) {
const node = findNode(treeNodes.value, `${connectionId}:${database}:${schema}:__extensions`);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node)) return;
const extensions = await withMetadataLoadTimeout(connectionId, api.listExtensions(connectionId, database, schema), "extensions");
const children: TreeNode[] = extensions.map((ext) => ({
id: `${node.id}:${ext.name}`,
label: ext.name,
type: "extension" as const,
connectionId,
database,
schema,
comment: ext.comment ?? null,
meta: ext,
isExpanded: false,
}));
setChildren(node, children);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
async function loadMoreObjectGroupChildren(node: TreeNode) {
if (node.type !== "load-more" || !node.loadMore) return;
const loadMore = node.loadMore;
const parent = findNode(treeNodes.value, node.loadMore.parentId);
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
const parentConnectionId = parent.connectionId;
const configForScope = getConfig(parentConnectionId);
const objectTypesForScope = objectTypesForGroupNode(parent.type);
return runTreeMetadataLoad(
{
kind: "object-group-page",
connectionId: parentConnectionId,
database: parent.database,
schema: parent.schema,
nodeKind: parent.type,
objectTypes: objectTypesForScope,
limit: loadMore.pageSize + 1,
offset: loadMore.offset,
sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay,
driverProfile: metadataDriverProfile(configForScope),
},
async () => {
node.isLoading = true;
try {
await ensureConnected(parentConnectionId);
if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") {
const parentDatabase = parent.database;
if (!parentDatabase) return;
const config = getConfig(parentConnectionId);
const querySchema = connectionObjectTreeQuerySchema(config, parentDatabase, parent.schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, parentDatabase, parent.schema);
const page = await loadPagedSimpleTableChildren({
nodeId: parent.schema ? `${parentConnectionId}:${parentDatabase}:${parent.schema}` : `${parentConnectionId}:${parentDatabase}`,
connectionId: parentConnectionId,
database: parentDatabase,
querySchema,
effectiveSchema,
nonTableObjectTypes: [],
offset: loadMore.offset,
pageSize: loadMore.pageSize,
force: false,
});
const currentChildren = withoutTableTreeLoadMoreNodes(parent.children);
const mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase);
const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(parent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren;
if (!canApplyTreeMetadataResult(parent)) return;
parent.objectCount = mergedChildren.length;
setChildren(parent, nextChildren);
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", "objects-simple-v5"), nextChildren);
parent.isExpanded = true;
return;
}
const objectTypes = objectTypesForGroupNode(parent.type);
const parentNodeId = objectGroupRefreshParentId(parent);
if (!objectTypes || !parentNodeId) return;
const config = getConfig(parentConnectionId);
const parentDatabase = parent.database;
if (!parentDatabase) return;
const querySchema = connectionObjectTreeQuerySchema(config, parentDatabase, parent.schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, parentDatabase, parent.schema);
const wantsOnlyTablesOrViews = objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW");
let mergedChildren: TreeNode[];
let nextChildren: TreeNode[];
if (wantsOnlyTablesOrViews) {
const page = await loadPagedTableGroupChildren({
node: parent,
parentNodeId,
querySchema,
effectiveSchema,
objectTypes,
offset: loadMore.offset,
pageSize: loadMore.pageSize,
force: false,
});
const currentChildren = withoutTableTreeLoadMoreNodes(parent.children);
mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase);
nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(parent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren;
} else {
const page = await loadPagedObjectGroupChildren({
node: parent,
parentNodeId,
querySchema,
effectiveSchema,
objectTypes,
offset: loadMore.offset,
pageSize: loadMore.pageSize,
force: false,
});
const currentChildren = withoutLoadMoreNodes(parent.children);
mergedChildren = mergeLocatedTreeChildren(parent, currentChildren, page.children, parentConnectionId, parentDatabase);
nextChildren = page.hasMore ? [...mergedChildren, buildLoadMoreNode(parent, page.nextOffset, loadMore.pageSize)] : mergedChildren;
}
if (!canApplyTreeMetadataResult(parent)) return;
parent.objectCount = mergedChildren.length;
setChildren(parent, nextChildren);
await savePersistedTreeChildren(objectGroupCacheKey(parent), nextChildren);
parent.isExpanded = true;
} catch (e) {
recordMetadataLoadError(parentConnectionId, e);
throw e;
} finally {
node.isLoading = false;
}
},
);
}
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 querySchema = connectionObjectTreeQuerySchema(config, target.database, target.schema);
const effectiveSchema = connectionObjectTreeNodeSchema(config, target.database, target.schema);
const pageSize = sidebarObjectGroupPageSize();
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
let loaded = false;
if (simpleObjectDisplay) {
const parentId = target.schema ? `${target.connectionId}:${target.database}:${target.schema}` : `${target.connectionId}:${target.database}`;
const parent = findNode(treeNodes.value, parentId);
if (!parent) return false;
const page = await loadPagedSimpleTableChildren({
nodeId: parentId,
connectionId: target.connectionId,
database: target.database,
querySchema,
effectiveSchema,
nonTableObjectTypes: [],
offset: 0,
pageSize,
searchFilter: target.tableName,
force: false,
});
if (!page.children.length) return false;
const currentChildren = withoutLoadMoreNodes(parent.children);
const loadMoreNodes = (parent.children || []).filter((child) => child.type === "load-more");
const mergedChildren = mergeLocatedTreeChildren(parent, currentChildren, page.children, target.connectionId, target.database);
if (!canApplyTreeMetadataResult(parent)) return false;
setChildren(parent, [...mergedChildren, ...loadMoreNodes]);
parent.objectCount = Math.max(parent.objectCount ?? currentChildren.length, mergedChildren.length);
parent.isExpanded = true;
return true;
}
const matchingGroups = findTreeNodes(treeNodes.value, (node) => {
return (node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views") && node.connectionId === target.connectionId && sameSidebarObjectName(node.database, target.database) && (!target.schema || sameSidebarObjectName(node.schema, target.schema));
});
for (const group of matchingGroups) {
const objectTypes = objectTypesForGroupNode(group.type);
const parentNodeId = objectGroupRefreshParentId(group);
if (!objectTypes || !parentNodeId) continue;
const page = await loadPagedTableGroupChildren({
node: group,
parentNodeId,
querySchema,
effectiveSchema,
objectTypes,
offset: 0,
pageSize,
searchFilter: target.tableName,
force: false,
});
if (!page.children.length) continue;
const currentChildren = withoutLoadMoreNodes(group.children);
const loadMoreNodes = (group.children || []).filter((child) => child.type === "load-more");
const mergedChildren = mergeLocatedTreeChildren(group, currentChildren, page.children, target.connectionId, target.database);
if (!canApplyTreeMetadataResult(group)) continue;
setChildren(group, [...mergedChildren, ...loadMoreNodes]);
group.objectCount = Math.max(group.objectCount ?? currentChildren.length, mergedChildren.length);
group.isExpanded = true;
loaded = true;
}
return loaded;
},
);
}
async function loadAllObjectGroupChildren(parent: TreeNode) {
if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
if (!objectTypesForGroupNode(parent.type)) return;
parent.isLoading = true;
try {
await ensureConnected(parent.connectionId);
if (!isTreeNodeChildrenLoaded(parent.id)) {
await loadObjectGroupChildren(parent);
}
let loadMoreNode = findTreeNodes(parent.children ?? [], (child) => child.type === "load-more")[0];
while (loadMoreNode?.loadMore) {
loadMoreNode.isLoading = true;
await loadMoreObjectGroupChildren(loadMoreNode);
loadMoreNode = findTreeNodes(parent.children ?? [], (child) => child.type === "load-more")[0];
}
parent.isExpanded = true;
} catch (e) {
recordMetadataLoadError(parent.connectionId, e);
throw e;
} finally {
parent.isLoading = false;
}
}
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;
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 metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(config));
if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) {
if (metadataCapabilities.indexes) {
children.push({
id: `${parentId}:__indexes`,
label: "tree.indexes",
type: "group-indexes",
connectionId,
database,
schema,
catalog,
tableName: table,
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: [],
});
}
}
setChildren(node, children);
node.isExpanded = true;
}
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;
node.isLoading = true;
try {
if (effectiveDatabaseTypeForConnection(getConfig(connectionId)) === "mongodb") {
const fields = await listMongoCompletionFields(connectionId, database, table);
setChildren(
node,
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,
};
}),
);
node.isExpanded = true;
return;
}
const querySchema = metadataQuerySchema(connectionId, database, schema);
const columns = await api.getColumns(connectionId, database, querySchema, table, catalog);
setChildren(
node,
columns.map((col) => ({
id: `${parentId}:${col.name}`,
label: `${col.name} (${col.data_type})`,
type: "column" as const,
connectionId,
database,
schema,
tableName: table,
meta: col,
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
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;
node.isLoading = true;
try {
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
if (!metadataCapabilities.indexes) {
setChildren(node, []);
node.isExpanded = true;
return;
}
const querySchema = metadataQuerySchema(connectionId, database, schema);
const indexes = await api.listIndexes(connectionId, database, querySchema, table, catalog);
setChildren(
node,
indexes.map((idx) => ({
id: `${parentId}:${idx.name}`,
label: `${idx.name} (${idx.columns.join(", ")})`,
type: "index" as const,
connectionId,
database,
schema,
tableName: table,
meta: idx,
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
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;
node.isLoading = true;
try {
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
if (!metadataCapabilities.foreignKeys) {
setChildren(node, []);
node.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));
setChildren(
node,
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,
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
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;
node.isLoading = true;
try {
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
if (!metadataCapabilities.triggers) {
setChildren(node, []);
node.isExpanded = true;
return;
}
const querySchema = metadataQuerySchema(connectionId, database, schema);
const triggers = await api.listTriggers(connectionId, database, querySchema, table, catalog);
setChildren(
node,
triggers.map((tr) => ({
id: `${parentId}:${tr.name}`,
label: `${tr.name} (${tr.timing} ${tr.event})`,
type: "trigger" as const,
connectionId,
database,
schema,
tableName: table,
meta: tr,
})),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
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) {
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 === "mongodb") {
await loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "elasticsearch") {
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 === "nacos") {
await loadNacosNamespaces(node.connectionId, options);
} else {
await loadDatabases(node.connectionId, options);
}
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
await loadMongoCollections(node.connectionId, node.database);
} else if (node.type === "vector-database" && node.connectionId && node.database) {
await loadVectorCollections(node.connectionId, node.database);
} else if (node.type === "mongo-collection" && node.connectionId && node.database) {
await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id);
} else if (node.type === "mongo-gridfs") {
node.isExpanded = true;
} else if (node.type === "doris-catalog" && node.connectionId) {
await loadDorisCatalogDatabases(node, options);
} else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
if (node.catalog && node.catalog !== "internal") {
await loadDorisCatalogTables(node, options);
} else {
const config = getConfig(node.connectionId);
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
if (config?.db_type === "sqlserver") {
await loadSqlServerDatabaseObjects(node.connectionId, node.database, options);
} else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) {
await loadSchemas(node.connectionId, node.database, options);
} else {
await loadTables(node.connectionId, node.database, undefined, options);
}
}
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) {
await loadTables(node.connectionId, node.database, node.schema, options);
} else if (node.type === "linked-server-root" && node.connectionId) {
await loadSqlServerLinkedServers(node.connectionId, options);
} else if (node.type === "linked-server" && node.connectionId) {
await loadSqlServerLinkedServerCatalogs(node, options);
} else if (node.type === "linked-server-catalog" && node.connectionId) {
await loadSqlServerLinkedServerSchemas(node, options);
} else if (node.type === "linked-server-schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) {
await loadTables(node.connectionId, node.database, node.schema, options);
} else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) {
await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog);
} else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-indexes" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
} else if (node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views" || node.type === "group-procedures" || node.type === "group-functions" || node.type === "group-sequences" || node.type === "group-packages") {
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 || "", node.schema || "");
}
}
async function restoreExpandedChildren(node: TreeNode, expandedIds: Set<string>, options?: LoadTreeOptions) {
if (!node.children) return;
for (const child of node.children) {
if (!expandedIds.has(child.id)) continue;
await loadTreeNodeChildren(child, options);
await restoreExpandedChildren(child, expandedIds, options);
}
}
async function refreshTreeNode(node: TreeNode) {
invalidateMetadataCachesForNode(node);
if (objectTypesForGroupNode(node.type)) {
clearLoadedChildrenCache(node.id);
await loadObjectGroupChildren(node, { force: true });
return;
}
const parentId = objectGroupRefreshParentId(node);
const parentNode = parentId ? findNode(treeNodes.value, parentId) : null;
if (parentNode) {
await refreshTreeNode(parentNode);
return;
}
if (node.connectionId && !connectedIds.value.has(node.connectionId)) return;
const expandedIds = collectExpandedNodeIds([node]);
expandedIds.add(node.id);
await clearPersistedTreeCacheForNode(node);
clearLoadedChildrenCache(node.id);
if (node.type !== "connection-group") {
node.children = [];
}
await loadTreeNodeChildren(node, { force: true });
await restoreExpandedChildren(node, expandedIds, { force: true });
}
async function refreshDatabaseTreeNode(connectionId: string, database: string) {
const node = findDatabaseTreeNode(treeNodes.value, connectionId, database);
if (node) {
await refreshTreeNode(node);
return;
}
await loadDatabases(connectionId, { force: true });
}
async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string) {
invalidateMetadataCaches({ connectionId, database, schema });
const shouldRefreshSchemaNode = !!schema;
const node = shouldRefreshSchemaNode ? findNode(treeNodes.value, `${connectionId}:${database}:${schema}`) : null;
if (node) {
await refreshTreeNode(node);
return;
}
await refreshDatabaseTreeNode(connectionId, database);
}
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 === "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 completionColumnsKey(connectionId: string, database: string, table: string, schema?: string): string {
return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}`;
}
function completionForeignKeysKey(connectionId: string, database: string, table: string, schema?: string): string {
return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}:fkeys`;
}
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 ?? "",
match_mode: request.match_mode ?? "prefix",
});
}
async function completionAssistantSearch(request: CompletionAssistantRequest) {
return withCompletionInFlight(`assistant:${completionAssistantRequestKey(request)}`, async () => {
await ensureConnected(request.connection_id);
return api.completionAssistantSearch(request);
});
}
const ORACLE_SYSTEM_COMPLETION_SCHEMAS = new Set(["SYS", "SYSTEM", "SYSMAN", "DBSNMP", "OUTLN", "XDB", "MDSYS", "CTXSYS", "WMSYS"]);
function completionPreferredSchema(connectionId: string, preferredSchema?: string): string | undefined {
return preferredSchema?.trim() || getConfig(connectionId)?.username?.trim() || undefined;
}
function completionCandidateSchemaBoost(schema: string | null | undefined, preferredSchema?: string): number {
if (schema && preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase()) return 2400;
if (schema?.toUpperCase() === "PUBLIC") return 1200;
if (schema && ORACLE_SYSTEM_COMPLETION_SCHEMAS.has(schema.toUpperCase())) return -1200;
return 0;
}
function completionCandidateApplyName(name: string, schema: string | null | undefined, preferredSchema?: string): string {
if (!schema || schema.toUpperCase() === "PUBLIC" || (preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase())) return name;
return `${schema}.${name}`;
}
function completionAssistantTables(candidates: CompletionAssistantCandidate[], preferredSchema?: string, withOracleMetadata = false): SqlCompletionTable[] {
return candidates
.filter((candidate) => candidate.kind === "table" || candidate.kind === "view")
.map((candidate) => {
const table: SqlCompletionTable = {
name: candidate.name,
schema: candidate.schema ?? undefined,
type: sqlObjectNavigationTypeFromTableType(candidate.data_type || candidate.kind),
};
if (!withOracleMetadata) return table;
return {
...table,
detail: candidate.schema ? `${candidate.schema} · ${(candidate.data_type || candidate.kind).toLowerCase()}` : candidate.kind,
applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema),
boost: completionCandidateSchemaBoost(candidate.schema, preferredSchema),
};
});
}
function completionAssistantObjects(candidates: CompletionAssistantCandidate[], preferredSchema?: string): SqlCompletionObject[] {
return candidates
.map((candidate): SqlCompletionObject | null => {
const candidateType = candidate.data_type?.toUpperCase();
const type = candidate.kind === "procedure" ? "procedure" : candidate.kind === "function" ? "function" : candidate.kind === "object" && candidateType === "PACKAGE" ? "package" : null;
if (!type) return null;
return {
name: candidate.name,
schema: candidate.schema ?? undefined,
type,
parentSchema: candidate.parent_schema ?? undefined,
parentName: candidate.parent_name ?? undefined,
applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema),
boost: completionCandidateSchemaBoost(candidate.schema, preferredSchema),
};
})
.filter((object): object is SqlCompletionObject => object != null);
}
function completionAssistantColumns(candidates: CompletionAssistantCandidate[], table: string, schema?: string): SqlCompletionColumn[] {
return candidates
.filter((candidate) => candidate.kind === "column")
.map((candidate) => ({
name: candidate.name,
table: candidate.parent_name ?? table,
schema: candidate.parent_schema ?? candidate.schema ?? schema,
dataType: candidate.data_type ?? undefined,
comment: candidate.comment ?? null,
}));
}
async function listCompletionAssistantTables(connectionId: string, database: string, filter: string, limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise<SqlCompletionTable[]> {
const oracleAssistant = getConfig(connectionId)?.db_type === "oracle";
const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, globalSearch ? currentSchema : (schema ?? currentSchema)) : schema?.trim() || undefined;
const objectKinds: CompletionAssistantObjectKind[] = ["table", "view"];
const response = await completionAssistantSearch({
connection_id: connectionId,
database,
schema: preferredSchema ?? null,
object_kinds: objectKinds,
mask: filter.trim(),
max_results: limit ?? 200,
global_search: globalSearch,
parent_schema: globalSearch ? null : (schema ?? null),
match_mode: "prefix",
});
const tables = completionAssistantTables(response.candidates, preferredSchema, oracleAssistant);
indexCompletionTables(connectionId, database, schema, tables);
return tables;
}
async function listCompletionAssistantObjects(connectionId: string, database: string, filter: string, limit: number | undefined, schema: string | undefined, parentName: string | undefined, globalSearch: boolean, currentSchema?: string): Promise<SqlCompletionObject[]> {
const preferredSchema = completionPreferredSchema(connectionId, currentSchema);
const response = await completionAssistantSearch({
connection_id: connectionId,
database,
schema: preferredSchema ?? null,
object_kinds: ["routine"],
mask: filter.trim(),
max_results: limit ?? 200,
global_search: globalSearch,
parent_schema: globalSearch ? null : (schema ?? null),
parent_name: parentName ?? null,
match_mode: "prefix",
});
const objects = completionAssistantObjects(response.candidates, preferredSchema);
indexCompletionObjects(connectionId, database, schema, objects);
return objects;
}
async function listCompletionAssistantColumns(connectionId: string, database: string, table: string, schema?: string): 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",
});
const columns = completionAssistantColumns(response.candidates, table, schema);
if (columns.length > 0) indexCompletionColumns(connectionId, database, table, schema, columns);
return columns;
}
function completionNameSegments(name: string): string[] {
return name
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.split(/[\s_.:-]+/)
.map((segment) => segment.trim().toLowerCase())
.filter(Boolean);
}
function completionNameAcronym(name: string): string {
return completionNameSegments(name)
.map((segment) => segment[0])
.join("");
}
function orderedSubsequenceScore(text: string, filter: string): number {
let index = 0;
let gaps = 0;
for (const ch of filter) {
const found = text.indexOf(ch, index);
if (found < 0) return -1;
gaps += found - index;
index = found + 1;
}
return 1_000 - gaps - text.length;
}
function tableMatchScore(table: SqlCompletionTable, filter: string, preferredSchema?: string): number {
const text = table.name.toLowerCase();
const schema = table.schema?.toLowerCase();
const normalized = filter.trim().toLowerCase();
let score = schema && preferredSchema && schema === preferredSchema.toLowerCase() ? 10_000 : 0;
if (!normalized) return score;
if (text === normalized) return score + 9_000 - text.length;
if (text.startsWith(normalized)) return score + 7_500 - text.length;
const segments = completionNameSegments(table.name);
if (segments.some((segment) => segment.startsWith(normalized))) return score + 7_200 - text.length;
const acronym = completionNameAcronym(table.name);
if (acronym === normalized) return score + 7_100 - text.length;
if (acronym.startsWith(normalized)) return score + 6_900 - text.length;
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[]) {
const groups = new Map<string, SqlCompletionTable[]>();
for (const table of tables) {
const tableSchema = table.schema ?? schema;
const key = completionScopeKey(connectionId, database, tableSchema);
const list = groups.get(key) ?? [];
list.push({ ...table, 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[]) {
touchCompletionIndex(completionColumnIndex, completionColumnsKey(connectionId, database, table, schema), {
columns,
});
}
function sqlCompletionForeignKeys(foreignKeys: ForeignKeyInfo[]): SqlCompletionForeignKey[] {
return foreignKeys.map((foreignKey) => ({
name: foreignKey.name,
column: foreignKey.column,
ref_schema: foreignKey.ref_schema,
ref_table: foreignKey.ref_table,
ref_column: foreignKey.ref_column,
}));
}
function indexCompletionForeignKeys(connectionId: string, database: string, table: string, schema: string | undefined, foreignKeys: SqlCompletionForeignKey[]) {
touchCompletionIndex(completionForeignKeyIndex, completionForeignKeysKey(connectionId, database, table, schema), {
foreignKeys,
});
}
function lookupLocalCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string): SqlCompletionTable[] {
const allScopes = [...completionTableIndex.entries()].filter(([key]) => key.startsWith(`${connectionId}:${database}:`)).map(([, entry]) => entry);
const preferred = schema ? completionTableIndex.get(completionScopeKey(connectionId, database, schema)) : undefined;
const scopes = schema ? (preferred ? [preferred] : []) : allScopes;
const treeTables = completionTablesFromTree(treeNodes.value, connectionId, database, schema);
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): SqlCompletionColumn[] {
return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema))?.columns ?? [];
}
function lookupLocalCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): SqlCompletionForeignKey[] {
return completionForeignKeyIndex.get(completionForeignKeysKey(connectionId, database, table, schema))?.foreignKeys ?? [];
}
function databaseNamesFromTree(connectionId: string): string[] {
const node = findNode(treeNodes.value, 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);
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 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];
}
// Upper bound on cached key names per db, to keep completion memory bounded
// (Redis can hold far more keys than we ever want resident for autocomplete).
const REDIS_COMPLETION_KEYS_MAX = 1000;
async function listRedisCompletionKeys(connectionId: string, database: string): Promise<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).slice(0, REDIS_COMPLETION_KEYS_MAX);
redisCompletionKeysCache.value[cacheKey] = keys;
evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX);
return keys;
});
}
async function listMongoCompletionCollections(connectionId: string, database: string): Promise<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;
});
}
async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string): 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}:${trimmedFilter}:${limit ?? ""}:${schema ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}`;
if (completionTablesCache.value[cacheKey]) {
return completionTablesCache.value[cacheKey];
}
return withCompletionInFlight(
`${cacheKey}:tables`,
async () => {
await ensureConnected(connectionId);
if (isSchemaAwareDatabase(connectionId)) {
if (normalizedFilter || limit) {
let results: SqlCompletionTable[] = [];
try {
results = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema);
} catch {
if (schema) {
const tables = await api.listTables(connectionId, database, schema, trimmedFilter, limit);
results = tables.map((table) => ({
name: table.name,
schema,
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
} else {
results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit);
}
}
if (results.length === 0 && relaxedFilter) {
if (globalSearch) {
try {
results = await listCompletionAssistantTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), schema, true, currentSchema);
} catch {
results = [];
}
} else if (schema) {
try {
const tables = await api.listTables(connectionId, database, schema, relaxedFilter, expandedCompletionLimit(limit));
results = tables.map((table) => ({
name: table.name,
schema,
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
} catch {
results = [];
}
} else {
results = lookupLocalCompletionTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit));
}
}
const limitedTables = limit ? dedupeCompletionTables(results).slice(0, limit) : results;
completionTablesCache.value[cacheKey] = limitedTables;
indexCompletionTables(connectionId, database, undefined, limitedTables);
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
return completionTablesCache.value[cacheKey];
}
if (schema) {
const tables = await api.listTables(connectionId, database, schema);
completionTablesCache.value[cacheKey] = tables.map((table) => ({
name: table.name,
schema,
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
} else {
completionTablesCache.value[cacheKey] = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit);
}
indexCompletionTables(connectionId, database, undefined, completionTablesCache.value[cacheKey]);
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
return completionTablesCache.value[cacheKey];
}
let tables = await api.listTables(connectionId, database, database, trimmedFilter, limit);
if (tables.length === 0 && relaxedFilter) {
tables = await api.listTables(connectionId, database, database, relaxedFilter, expandedCompletionLimit(limit));
}
completionTablesCache.value[cacheKey] = tables.map((table) => ({
name: table.name,
type: sqlObjectNavigationTypeFromTableType(table.table_type),
}));
completionTablesCache.value[cacheKey] = limit ? completionTablesCache.value[cacheKey].slice(0, limit) : completionTablesCache.value[cacheKey];
indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey]);
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.schema ?? ""}.${table.name}`.toLowerCase();
const existingIndex = indexByKey.get(key);
if (existingIndex != null) {
const existing = deduped[existingIndex];
// Loaded tree metadata can distinguish materialized views even when an older completion endpoint only reports VIEW.
deduped[existingIndex] = { ...table, ...existing, type: mergeSqlObjectNavigationType(existing.type, table.type) };
continue;
}
indexByKey.set(key, deduped.length);
deduped.push(table);
}
return deduped;
}
async function listCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string, parentName?: string, globalSearch = false, currentSchema?: string): Promise<SqlCompletionObject[]> {
const normalizedFilter = filter.trim().toLowerCase();
const oracleAssistant = getConfig(connectionId)?.db_type === "oracle" && (!!normalizedFilter || typeof limit === "number" || !!parentName || globalSearch);
const cacheKey = oracleAssistant ? `${connectionId}:${database}:${schema ?? ""}:${parentName ?? ""}:${normalizedFilter}:${limit ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}` : `${connectionId}:${database}:${schema ?? ""}`;
if (!completionObjectsCache.value[cacheKey]) {
await withCompletionInFlight(
`${cacheKey}:objects`,
async () => {
await ensureConnected(connectionId);
if (oracleAssistant) {
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema));
} else {
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database);
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
}
indexCompletionObjects(connectionId, database, schema, completionObjectsCache.value[cacheKey]);
evictOldestCacheEntries(completionObjectsCache.value, COMPLETION_CACHE_MAX);
},
{ scope: completionLimiterScope(connectionId, database), kind: "objects" },
);
}
const objects = completionObjectsCache.value[cacheKey];
const filtered = normalizedFilter ? objects.filter((object) => fuzzyCompletionObjectMatch(object, normalizedFilter)) : objects;
return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
}
async function listSchemaAwareCompletionObjects(connectionId: string, database: string, schema?: string): Promise<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,
};
}
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;
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 ?? ""}`.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): Promise<SqlCompletionColumn[]> {
if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(getConfig(connectionId)) && !schema) {
return [];
}
const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`;
if (!completionColumnsCache.value[cacheKey]) {
await withCompletionInFlight(
`${cacheKey}:columns`,
async () => {
await ensureConnected(connectionId);
try {
const assistantColumns = await listCompletionAssistantColumns(connectionId, database, table, schema);
if (assistantColumns.length > 0) {
completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({
name: column.name,
data_type: column.dataType ?? "",
is_nullable: column.isNullable ?? true,
column_default: null,
is_primary_key: false,
extra: null,
comment: column.comment ?? null,
numeric_precision: null,
numeric_scale: null,
character_maximum_length: null,
}));
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
return;
}
} catch {
// Fall back to the existing metadata path below.
}
const querySchema = metadataQuerySchema(connectionId, database, schema);
completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table);
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
},
{ scope: completionLimiterScope(connectionId, database), kind: "columns" },
);
}
const columns = completionColumnsCache.value[cacheKey].map((column) => ({
name: column.name,
table,
schema,
dataType: column.data_type,
isNullable: column.is_nullable,
comment: column.comment,
}));
indexCompletionColumns(connectionId, database, table, schema, columns);
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]) {
await withCompletionInFlight(
`${cacheKey}:fkeys`,
async () => {
await ensureConnected(connectionId);
const querySchema = metadataQuerySchema(connectionId, database, schema);
completionForeignKeysCache.value[cacheKey] = await api.listForeignKeys(connectionId, database, querySchema, table);
evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX);
},
{ scope: completionLimiterScope(connectionId, database), kind: "foreignKeys" },
);
}
const foreignKeys = sqlCompletionForeignKeys(completionForeignKeysCache.value[cacheKey]);
indexCompletionForeignKeys(connectionId, database, table, schema, foreignKeys);
return foreignKeys;
}
function refreshCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise<SqlCompletionTable[]> {
return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema);
}
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): Promise<SqlCompletionColumn[]> {
return listCompletionColumns(connectionId, database, table, schema);
}
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;
}
async function persistConnections(nextConnections: ConnectionConfig[] = connections.value) {
await api.saveConnections(nextConnections);
}
function persistSidebarLayoutDebounced() {
if (layoutPersistTimer) clearTimeout(layoutPersistTimer);
layoutPersistTimer = setTimeout(() => {
api.saveSidebarLayout(sidebarLayout.value).catch(() => {});
layoutPersistTimer = null;
}, 300);
}
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,
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;
});
treeNodes.value = mergeState(freshNodes);
}
function updateLayoutAndRebuild(nextLayout: SidebarLayout) {
sidebarLayout.value = nextLayout;
rebuildTreeNodes();
persistSidebarLayoutDebounced();
}
function collapseAllTreeNodes() {
updateLayoutAndRebuild(collapseAllGroupsOp(sidebarLayout.value));
collapseExpandedTreeNodes(treeNodes.value);
}
async function refreshAllTree() {
const expandedIds = collectExpandedNodeIds(treeNodes.value);
const refreshExpandedNodes = async (nodes: TreeNode[]) => {
for (const node of nodes) {
if (node.type === "connection-group") {
if (node.children) await refreshExpandedNodes(node.children);
continue;
}
if (!expandedIds.has(node.id)) continue;
if (node.connectionId && !connectedIds.value.has(node.connectionId)) continue;
clearLoadedChildrenCache(node.id);
node.children = [];
await loadTreeNodeChildren(node, { force: true });
await restoreExpandedChildren(node, expandedIds, { force: true });
}
};
await refreshExpandedNodes(treeNodes.value);
}
async function refreshSidebarObjectPagination() {
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
const isDirectObjectParent = (node: TreeNode) => {
if (!node.children || node.children.length === 0) return false;
return node.children.some(
(child) => child.type === "table" || child.type === "view" || child.type === "materialized_view" || child.type === "procedure" || child.type === "function" || child.type === "sequence" || child.type === "package" || child.type === "package-body" || child.type === "load-more",
);
};
const refreshNodes = async (nodes: TreeNode[]) => {
for (const node of nodes) {
if (node.type === "connection-group") {
if (node.children) await refreshNodes(node.children);
continue;
}
if (objectTypesForGroupNode(node.type)) {
if (node.connectionId && connectedIds.value.has(node.connectionId)) {
clearLoadedChildrenCache(node.id);
if (node.isExpanded) {
await loadObjectGroupChildren(node, { force: true });
} else if (node.children) {
node.children = [];
}
}
continue;
}
if (simpleObjectDisplay && (node.type === "database" || node.type === "schema" || node.type === "linked-server-schema")) {
if (isDirectObjectParent(node)) {
if (node.connectionId && connectedIds.value.has(node.connectionId)) {
clearLoadedChildrenCache(node.id);
if (node.isExpanded) {
await refreshTreeNode(node);
} else {
node.children = [];
}
}
continue;
}
if (node.children) await refreshNodes(node.children);
continue;
}
if (node.children) await refreshNodes(node.children);
}
};
await refreshNodes(treeNodes.value);
}
async function exportConnectionsToFile(passphrase: string) {
const { encryptConfig } = await import("@/lib/backend/configCrypto");
const tunnelProfileStore = useTunnelProfileStore();
await tunnelProfileStore.init();
const exportData = { connections: connections.value, layout: sidebarLayout.value, tunnelProfiles: tunnelProfileStore.profiles };
const json = JSON.stringify(exportData);
const payload = await encryptConfig(json, passphrase);
const content = JSON.stringify(payload, null, 2);
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const path = await save({
filters: [{ name: "JSON", extensions: ["json"] }],
defaultPath: "dbx-connections.json",
});
if (!path) return;
await writeTextFile(path, content);
} else {
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "dbx-connections.json";
a.click();
URL.revokeObjectURL(url);
}
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.slice(i, i + chunkSize));
}
return btoa(binary);
}
function siblingCredentialsPath(path: string) {
const fileName = path.split(/[\\/]/).pop() || "";
const credentialsFile = fileName.startsWith("data-sources-") ? fileName.replace(/^data-sources/, "credentials-config") : "credentials-config.json";
return path.replace(/[^\\/]+$/, credentialsFile);
}
async function readDbeaverImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
let dataSources: string;
let credentialsBase64 = "";
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile, readFile } = await import("@tauri-apps/plugin-fs");
const path = await open({
filters: [{ name: "DBeaver Data Sources", extensions: ["json"] }],
multiple: false,
});
if (!path) return null;
const dataSourcesPath = path as string;
dataSources = await readTextFile(dataSourcesPath);
try {
credentialsBase64 = bytesToBase64(await readFile(siblingCredentialsPath(dataSourcesPath)));
} catch {
credentialsBase64 = "";
}
} else {
const files = await new Promise<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 = "";
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const path = await open({
filters: [{ name: "DataGrip dataSources.xml", extensions: ["xml"] }],
multiple: false,
});
if (!path) return null;
dataSources = await readTextFile(path as string);
// Auto-load dataSources.local.xml from the same directory
const dir = (path as string).replace(/[^/\\]*$/, "");
try {
dataSourcesLocal = await readTextFile(dir + "dataSources.local.xml");
} catch {
dataSourcesLocal = "";
}
} 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)) || fileList[0];
const localFile = fileList.find((f) => /^dataSources\.local\.xml$/i.test(f.name));
if (!dsFile) throw new Error("Select dataSources.xml");
dataSources = await dsFile.text();
if (localFile) {
dataSourcesLocal = await localFile.text();
}
}
return {
content: JSON.stringify({ format: "datagrip-import", dataSources, dataSourcesLocal }),
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 importConnectionsFromFile(content: string, passphrase: string | null): Promise<{ count: number; layout?: SidebarLayout }> {
let imported: ConnectionConfig[] = [];
let importedLayout: SidebarLayout | undefined;
let importedTunnelProfiles: TunnelProfile[] = [];
if (!passphrase && content.trimStart().startsWith("<")) {
const { parseNavicatConnections } = await import("@/lib/imports/navicatImport");
imported = await parseNavicatConnections(content);
} else if (!passphrase) {
const { isDbeaverImportPayload, parseDbeaverConnections } = await import("@/lib/imports/dbeaverImport");
const { isDataGripImportPayload, parseDataGripConnections } = await import("@/lib/imports/datagripImport");
if (isDataGripImportPayload(content)) {
const payload = JSON.parse(content) as {
format: "datagrip-import";
dataSources: string;
dataSourcesLocal?: string;
};
pendingDataGripPayload = payload;
imported = parseDataGripConnections(payload);
} else if (isDbeaverImportPayload(content)) {
imported = await parseDbeaverConnections(content);
} else {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
imported = parsed;
} else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) {
imported = parsed.connections;
} else if (parsed.connections && Array.isArray(parsed.connections)) {
imported = parsed.connections;
if (parsed.layout?.groups && parsed.layout?.order) {
importedLayout = parsed.layout;
}
if (Array.isArray(parsed.tunnelProfiles)) {
importedTunnelProfiles = parsed.tunnelProfiles;
}
} else {
imported = [];
}
}
} else {
const parsed = JSON.parse(content);
if (passphrase) {
const { decryptConfig } = await import("@/lib/backend/configCrypto");
const json = await decryptConfig(parsed, passphrase);
const decrypted = JSON.parse(json);
if (Array.isArray(decrypted)) {
imported = decrypted;
} else if (decrypted.connections) {
imported = decrypted.connections;
if (decrypted.layout?.groups && decrypted.layout?.order) {
importedLayout = decrypted.layout;
}
if (Array.isArray(decrypted.tunnelProfiles)) {
importedTunnelProfiles = decrypted.tunnelProfiles;
}
} else {
imported = [];
}
}
}
// Profiles keep their original ids: imported connections reference them
// via transport_layers[].profile_id, so regenerating ids would break the
// links. Same-id profiles are overwritten with the imported copy.
if (importedTunnelProfiles.length) {
const tunnelProfileStore = useTunnelProfileStore();
await tunnelProfileStore.init();
const merged = [...tunnelProfileStore.profiles];
for (const profile of importedTunnelProfiles) {
if (!profile || typeof profile.id !== "string" || !profile.id) continue;
const index = merged.findIndex((existing) => existing.id === profile.id);
if (index >= 0) merged[index] = profile;
else merged.push(profile);
}
await tunnelProfileStore.saveProfiles(merged);
}
let count = 0;
const importedConnectionIdMap = new Map<string, string>();
for (const config of imported) {
const duplicate = connections.value.find((c) => c.name === config.name && c.host === config.host && c.port === config.port);
if (!duplicate) {
const importedId = config.id;
config.id = uuid();
if (typeof importedId === "string") importedConnectionIdMap.set(importedId, config.id);
const normalized = normalizeConnection(config);
await addConnection(normalized);
count++;
} else if (typeof config.id === "string") {
importedConnectionIdMap.set(config.id, duplicate.id);
}
}
if (importedLayout) {
importedLayout = remapSidebarLayoutConnectionIds(importedLayout, importedConnectionIdMap);
}
return { count, layout: importedLayout };
}
/** Read macOS Keychain passwords for DataGrip connections and update them. */
async function applyDataGripKeychainPasswords(): Promise<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),
layout,
);
updateLayoutAndRebuild(reconciledLayout);
}
async function initFromDisk() {
if (!initFromDiskPromise) {
initFromDiskPromise = (async () => {
pinnedTreeNodeIds.value = await loadPinnedTreeNodeIds();
const saved = await api.loadConnections();
connections.value = saved.map(normalizeConnection);
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);
}
return {
connections,
activeConnectionId,
selectedTreeNodeId,
selectedTreeNodeIds,
selectedTreeNodeIdsSet,
treeSelectionAnchorId,
connectionMultiSelectActive,
treeClipboard,
treeNodes,
removeTreeNode,
refreshAllTree,
collapseAllTreeNodes,
refreshSidebarObjectPagination,
refreshTreeNode,
refreshDatabaseTreeNode,
refreshObjectListTreeNode,
connectedIds,
connectingIds,
connectionErrors,
setConnectionError,
clearConnectionError,
recordConnectionError,
markConnectionLost,
recordConnectionLostError,
sidebarLayout,
connectionGroupPaths,
getConfig,
connectionIdentifierQuote,
isTreeNodePinned,
toggleTreeNodePin,
addConnection,
copyConnectionsToTreeClipboard,
pasteConnectionClipboard,
addEphemeralConnection,
updateConnection,
updateConnectionDatabaseInfo,
setDefaultDatabase,
clearDefaultDatabase,
isDefaultDatabase,
setVisibleDatabases,
clearVisibleDatabases,
ensureVisibleDatabase,
setVisibleSchemas,
clearVisibleSchemas,
removeConnection,
removeConnections,
editingConnectionId,
newConnectionGroupId,
startEditing,
stopEditing,
startCreatingConnectionInGroup,
stopCreatingConnectionInGroup,
connect,
cancelConnecting,
disconnect,
closeDatabaseConnection,
ensureConnected,
isTreeNodeChildrenLoaded,
releaseCollapsedTreeNodeChildren,
setBeforeConnectHandler,
initFromDisk,
loadDatabases,
loadRedisDatabases,
refreshRedisDbKeyCounts,
loadEtcdRoot,
loadZooKeeperRoot,
loadMqTenants,
loadNacosNamespaces,
updateRedisDbKeyStats,
loadMongoDatabases,
loadMilvusDatabases,
loadElasticsearchIndices,
loadVectorCollections,
loadMongoCollections,
loadSchemas,
loadSqlServerDatabaseObjects,
loadSqlServerLinkedServers,
loadSqlServerLinkedServerCatalogs,
loadSqlServerLinkedServerSchemas,
loadDorisCatalogDatabases,
loadDorisCatalogTables,
loadTables,
loadTableForLocate,
loadObjectGroupChildren,
loadMoreObjectGroupChildren,
loadAllObjectGroupChildren,
loadTableGroups,
loadColumns,
loadIndexes,
loadForeignKeys,
loadTriggers,
listCompletionTables,
listCompletionObjects,
listCompletionColumns,
listCompletionForeignKeys,
listCompletionSchemas,
listCompletionDatabases,
lookupLocalCompletionTables,
lookupLocalCompletionObjects,
lookupLocalCompletionColumns,
lookupLocalCompletionForeignKeys,
lookupLocalCompletionSchemas,
lookupLocalCompletionDatabases,
refreshCompletionTables,
refreshCompletionObjects,
refreshCompletionColumns,
refreshCompletionForeignKeys,
refreshCompletionSchemas,
refreshCompletionDatabases,
listElasticsearchCompletionIndices,
listRedisCompletionKeys,
listMongoCompletionCollections,
listMongoCompletionFields,
invalidateCompletionCache,
invalidateMetadataCache,
exportConnectionsToFile,
readImportFile,
importConnectionsFromFile,
applyDataGripKeychainPasswords,
applySidebarLayout,
transferSource,
schemaDiffSource,
dataCompareSource,
sqlFileSource,
diagramSource,
tableImportSource,
tableDataGenerateSource,
fieldLineageSource,
databaseSearchSource,
databaseExportSource,
sidebarSearchQuery,
sidebarTableSearchQueries,
setSidebarTableSearchQuery,
refreshSidebarTableSearch,
createConnectionGroup(name: string, parentGroupId?: string | null) {
const result = createGroupOp(sidebarLayout.value, name, parentGroupId);
updateLayoutAndRebuild(result.layout);
return result.groupId;
},
renameConnectionGroup(groupId: string, name: string) {
updateLayoutAndRebuild(renameGroupOp(sidebarLayout.value, groupId, name));
},
deleteConnectionGroup(groupId: string) {
updateLayoutAndRebuild(deleteGroupOp(sidebarLayout.value, groupId));
},
toggleConnectionGroupCollapsed(groupId: string) {
updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId));
},
moveConnectionToGroup(connectionId: string, groupId: string | null) {
updateLayoutAndRebuild(moveConnectionToGroupOp(sidebarLayout.value, connectionId, groupId));
},
groupIdForConnection(connectionId: string): string | null {
return findConnectionLocation(sidebarLayout.value, connectionId)?.groupId ?? null;
},
reorderSidebarEntry(draggedId: string, targetId: string, position: DropPosition) {
updateLayoutAndRebuild(reorderEntryOp(sidebarLayout.value, draggedId, targetId, position));
},
reorderSidebarEntries(draggedIds: string[], targetId: string, position: DropPosition) {
// Apply each dragged entry in turn so a multi-selection moves together,
// not just the single grabbed row (issue #681).
let layout = sidebarLayout.value;
let changed = false;
for (const id of draggedIds) {
if (id === targetId) continue;
layout = reorderEntryOp(layout, id, targetId, position);
changed = true;
}
if (changed) updateLayoutAndRebuild(layout);
},
};
});