fix(connection): clear warnings after metadata recovery

This commit is contained in:
zipg authored and GitHub committed 2026-07-17 16:06:35 +08:00
1 parent 9ba61756db
commit d41a6d8944
10 files changed
+226 -9

No files matched your search

@@ -212,6 +212,187 @@ describe("connectionStore metadata loading", () => {
expect(schemaNode.children?.map((node) => node.label)).toEqual(["users"]);
});
it("clears a stale connection error after a schema metadata retry succeeds", async () => {
const listSchemaInfos = vi
.fn()
.mockRejectedValueOnce(new Error("connection slots exhausted"))
.mockResolvedValueOnce([{ name: "public", comment: null }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listSchemaInfos,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [
{
id: `${connection.id}:app`,
label: "app",
type: "database",
connectionId: connection.id,
database: "app",
isExpanded: false,
children: [],
},
],
},
];
await expect(store.loadSchemas(connection.id, "app", { force: true })).rejects.toThrow("connection slots exhausted");
expect(store.connectionErrors[connection.id]).toBe("connection slots exhausted");
await store.loadSchemas(connection.id, "app", { force: true });
expect(store.connectionErrors[connection.id]).toBeUndefined();
expect(store.treeNodes[0]?.children?.[0]?.children?.map((node) => node.label)).toEqual(["public"]);
});
it("clears a failed metadata warning when the driver hint finishes during retry", async () => {
let resolveAgents!: (drivers: Array<{ db_type: string; installed: boolean; update_available: boolean }>) => void;
let resolveSchemas!: (schemas: Array<{ name: string; comment: null }>) => void;
const listInstalledAgents = vi.fn(
() =>
new Promise<Array<{ db_type: string; installed: boolean; update_available: boolean }>>((resolve) => {
resolveAgents = resolve;
}),
);
const listSchemaInfos = vi
.fn()
.mockRejectedValueOnce(new Error("connection slots exhausted"))
.mockImplementationOnce(
() =>
new Promise<Array<{ name: string; comment: null }>>((resolve) => {
resolveSchemas = resolve;
}),
);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents,
listSchemaInfos,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = { ...postgresConnection(), db_type: "oracle" } as ConnectionConfig;
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [
{
id: `${connection.id}:app`,
label: "app",
type: "database",
connectionId: connection.id,
database: "app",
isExpanded: false,
children: [],
},
],
},
];
await expect(store.loadSchemas(connection.id, "app", { force: true })).rejects.toThrow("connection slots exhausted");
expect(store.connectionErrors[connection.id]).toBe("connection slots exhausted");
const retry = store.loadSchemas(connection.id, "app", { force: true });
await vi.waitFor(() => expect(listSchemaInfos).toHaveBeenCalledTimes(2));
resolveAgents([{ db_type: "oracle", installed: true, update_available: true }]);
await vi.waitFor(() => expect(store.connectionErrors[connection.id]).toContain("built-in driver update"));
resolveSchemas([{ name: "public", comment: null }]);
await retry;
expect(store.connectionErrors[connection.id]).toBeUndefined();
});
it("does not clear a newer error when an older metadata request succeeds", async () => {
let resolveSchemas!: (schemas: Array<{ name: string; comment: null }>) => void;
const listSchemaInfos = vi.fn(
() =>
new Promise<Array<{ name: string; comment: null }>>((resolve) => {
resolveSchemas = resolve;
}),
);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listSchemaInfos,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [
{
id: `${connection.id}:app`,
label: "app",
type: "database",
connectionId: connection.id,
database: "app",
isExpanded: false,
children: [],
},
],
},
];
store.setConnectionError(connection.id, "old error");
const load = store.loadSchemas(connection.id, "app", { force: true });
await vi.waitFor(() => expect(listSchemaInfos).toHaveBeenCalledOnce());
store.setConnectionError(connection.id, "newer error");
resolveSchemas([{ name: "public", comment: null }]);
await load;
expect(store.connectionErrors[connection.id]).toBe("newer error");
});
it("keeps an expanding schema attached while its parent refreshes", async () => {
const listSchemaInfos = vi.fn().mockResolvedValue([{ name: "core", comment: null }]);
+28 -4
View File
@@ -352,6 +352,7 @@ export const useConnectionStore = defineStore("connection", () => {
const cancelledLocalConnectionAttempts = new Map<string, Set<number>>();
const successfulLocalConnectionAttempts = new Map<string, number>();
const connectionStateRevisions = new Map<string, number>();
const connectionErrorRevisions = new Map<string, number>();
let nextLocalConnectionAttempt = 0;
let beforeConnectHandler: BeforeConnectHandler | null = null;
let initFromDiskPromise: Promise<void> | null = null;
@@ -375,7 +376,9 @@ export const useConnectionStore = defineStore("connection", () => {
}
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: options?.force ? "refresh" : "miss", force: options?.force === true });
const errorRevision = connectionErrorRevision(scope.connectionId);
const result = await load();
clearConnectionErrorIfUnchanged(scope.connectionId, errorRevision);
metadataListPageCache.set(scope, result);
logMetadataLoadTrace(metadataTraceLogger, trace, "done", {
cacheStatus: options?.force ? "refresh" : "miss",
@@ -610,6 +613,22 @@ export const useConnectionStore = defineStore("connection", () => {
function setConnectionError(connectionId: string, message: string) {
connectionErrors.value[connectionId] = message;
connectionErrorRevisions.set(connectionId, connectionErrorRevision(connectionId) + 1);
}
function updateConnectionErrorPresentation(connectionId: string, expectedMessage: string, message: string) {
// Hints decorate the current error event and must not invalidate a retry's recovery revision.
if (connectionErrors.value[connectionId] !== expectedMessage) return;
connectionErrors.value[connectionId] = message;
}
function connectionErrorRevision(connectionId?: string | null): number {
return connectionId ? (connectionErrorRevisions.get(connectionId) ?? 0) : 0;
}
function clearConnectionErrorIfUnchanged(connectionId: string | null | undefined, revision: number) {
if (!connectionId || connectionErrorRevision(connectionId) !== revision) return;
clearConnectionError(connectionId);
}
function agentDriverUpdateHint(): string {
@@ -641,19 +660,20 @@ export const useConnectionStore = defineStore("connection", () => {
const config = getConfig(connectionId);
const message = connectionErrorWithDriverUpdateHint(config, baseMessage);
if (message !== baseMessage) {
setConnectionError(connectionId, message);
updateConnectionErrorPresentation(connectionId, baseMessage, message);
return;
}
void refreshAgentDriversForErrorHint().then(() => {
if (connectionErrors.value[connectionId] !== baseMessage) return;
const refreshedMessage = connectionErrorWithDriverUpdateHint(config, baseMessage);
if (refreshedMessage !== baseMessage) setConnectionError(connectionId, refreshedMessage);
if (refreshedMessage !== baseMessage) updateConnectionErrorPresentation(connectionId, baseMessage, refreshedMessage);
});
}
function clearConnectionError(connectionId: string) {
if (!connectionErrors.value[connectionId]) return;
delete connectionErrors.value[connectionId];
connectionErrorRevisions.set(connectionId, connectionErrorRevision(connectionId) + 1);
}
function markConnectionHealthChecked(connectionId: string) {
@@ -703,9 +723,10 @@ export const useConnectionStore = defineStore("connection", () => {
async function withMetadataLoadTimeout<T>(connectionId: string, promise: Promise<T>, label: string): Promise<T> {
const timeoutMs = metadataLoadTimeoutMs(getConfig(connectionId));
const errorRevision = connectionErrorRevision(connectionId);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
const result = await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
@@ -713,6 +734,8 @@ export const useConnectionStore = defineStore("connection", () => {
}, timeoutMs);
}),
]);
clearConnectionErrorIfUnchanged(connectionId, errorRevision);
return result;
} finally {
if (timer) clearTimeout(timer);
}
@@ -1753,6 +1776,7 @@ export const useConnectionStore = defineStore("connection", () => {
persistPinnedTreeNodeIds();
for (const id of removedIds) {
clearConnectionError(id);
connectionErrorRevisions.delete(id);
connectedIds.value.delete(id);
clearConnectionIdentifierQuote(id);
clearConnectionHealthCheck(id);
@@ -2821,7 +2845,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
const schemas = sortSidebarSchemaInfos(await api.listSchemaInfos(connectionId, database));
const schemas = sortSidebarSchemaInfos(await withMetadataLoadTimeout(connectionId, api.listSchemaInfos(connectionId, database), "schemas"));
const visibleSchemaNames = new Set(
filterSchemaNamesForConnection(
schemas.map((schema) => schema.name),