fix(metadata): await object cache invalidation on connection refresh

This commit is contained in:
soddygo authored and GitHub committed 2026-09-15 12:21:18 +08:00
1 parent f95650f039
commit faf259fcc2
17 files changed
+536 -32

No files matched your search

@@ -1,5 +1,6 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isObjectCacheInvalidationError } from "@/lib/metadata/objectCacheInvalidationError";
import type { ConnectionConfig, TreeNode } from "@/types/database";
function installLocalStorage() {
@@ -167,3 +168,202 @@ describe("connectionStore tree refresh state", () => {
expect(store.completionCacheRevision("pg-1", "app")).toBeGreaterThan(0);
});
});
describe("connection node refresh awaits object cache invalidation", () => {
function installConnectionApiMocks(options?: { deleteSchemaCachePrefix?: ReturnType<typeof vi.fn>; getColumns?: ReturnType<typeof vi.fn>; getTableDisplayDdl?: ReturnType<typeof vi.fn>; listDatabases?: ReturnType<typeof vi.fn>; listSchemaInfos?: ReturnType<typeof vi.fn> }) {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: options?.deleteSchemaCachePrefix ?? vi.fn().mockResolvedValue(undefined),
getColumns: options?.getColumns ?? vi.fn().mockResolvedValue([]),
getTableDisplayDdl: options?.getTableDisplayDdl ?? vi.fn().mockResolvedValue("CREATE TABLE users (id int)"),
listDatabases: options?.listDatabases ?? vi.fn().mockResolvedValue([{ name: "app", comment: null }]),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listObjects: vi.fn().mockResolvedValue([]),
listSchemaInfos: options?.listSchemaInfos ?? vi.fn().mockResolvedValue([{ name: "public", comment: null }]),
listTables: vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
}
/** A database node under the connection node: refresh reloads it via listSchemaInfos. */
function databaseNode(): TreeNode {
return { id: "pg-1:app", label: "app", type: "database", connectionId: "pg-1", database: "app", isExpanded: true, children: [] };
}
async function createStoreWithConnection(connectionId = "pg-1") {
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped";
const connection: ConnectionConfig = {
...postgresConnection(),
id: connectionId,
};
store.connections = [connection];
store.connectedIds.add(connection.id);
const connectionNode: TreeNode = {
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [],
};
store.treeNodes = [connectionNode];
return { store, connectionNode };
}
function objectCacheDeletePrefixes(mock: ReturnType<typeof vi.fn>): string[] {
return mock.mock.calls.filter(([prefix]) => (prefix as string).startsWith("object-")).map(([prefix]) => prefix as string);
}
/** A prefix-deletion mock whose object-cache deletions share one deferred gate. */
function gatedObjectCacheDeletes() {
const deleteSchemaCachePrefix = vi.fn((prefix: string) => {
if (!prefix.startsWith("object-")) return Promise.resolve();
return gate.then(() => undefined);
});
let resolveGate!: () => void;
const gate = new Promise<void>((resolve) => {
resolveGate = resolve;
});
return { deleteSchemaCachePrefix, releaseObjectCacheDeletes: () => resolveGate() };
}
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("blocks the tree reload until both object cache deletions settle", async () => {
const { deleteSchemaCachePrefix, releaseObjectCacheDeletes } = gatedObjectCacheDeletes();
const listDatabases = vi.fn().mockResolvedValue([{ name: "app", comment: null }]);
installConnectionApiMocks({ deleteSchemaCachePrefix, listDatabases });
const { store, connectionNode } = await createStoreWithConnection();
let settled = false;
const refresh = store.refreshConnectionTreeNode(connectionNode).finally(() => {
settled = true;
});
await vi.waitFor(() => expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).length).toBe(2));
expect(settled).toBe(false);
expect(listDatabases).not.toHaveBeenCalled();
releaseObjectCacheDeletes();
await refresh;
expect(listDatabases).toHaveBeenCalledTimes(1);
expect(listDatabases).toHaveBeenCalledWith("pg-1");
});
it("issues exactly one object-cache deletion per namespace on a connection refresh", async () => {
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
installConnectionApiMocks({ deleteSchemaCachePrefix });
const { store, connectionNode } = await createStoreWithConnection();
await store.refreshConnectionTreeNode(connectionNode);
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).sort()).toEqual(["object-ddl:v1:pg-1:", "object-meta:v1:pg-1:"]);
});
it("lets concurrent connection refreshes share the pending deletions", async () => {
const { deleteSchemaCachePrefix, releaseObjectCacheDeletes } = gatedObjectCacheDeletes();
installConnectionApiMocks({ deleteSchemaCachePrefix });
const { store, connectionNode } = await createStoreWithConnection();
const first = store.refreshConnectionTreeNode(connectionNode);
const second = store.refreshConnectionTreeNode(connectionNode);
await vi.waitFor(() => expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).length).toBe(2));
releaseObjectCacheDeletes();
await Promise.all([first, second]);
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix)).toHaveLength(2);
});
it("rejects with the object cache error and skips the tree reload when deletion fails", async () => {
const deleteSchemaCachePrefix = vi.fn((prefix: string) => (prefix.startsWith("object-") ? Promise.reject(new Error("sqlite locked")) : Promise.resolve()));
const listDatabases = vi.fn().mockResolvedValue([{ name: "app", comment: null }]);
installConnectionApiMocks({ deleteSchemaCachePrefix, listDatabases });
const { store, connectionNode } = await createStoreWithConnection();
const previousChildren = connectionNode.children;
const failure = await store.refreshConnectionTreeNode(connectionNode).then(
() => undefined,
(error: unknown) => error,
);
expect(isObjectCacheInvalidationError(failure)).toBe(true);
expect((failure as Error).message).toBe("sqlite locked");
expect(listDatabases).not.toHaveBeenCalled();
expect(connectionNode.children).toBe(previousChildren);
});
it("retries the deletion and completes the tree reload on a subsequent refresh", async () => {
let objectDeletionFailures = 2;
const deleteSchemaCachePrefix = vi.fn((prefix: string) => {
if (!prefix.startsWith("object-")) return Promise.resolve();
return objectDeletionFailures-- > 0 ? Promise.reject(new Error("locked")) : Promise.resolve();
});
const listDatabases = vi.fn().mockResolvedValue([{ name: "app", comment: null }]);
installConnectionApiMocks({ deleteSchemaCachePrefix, listDatabases });
const { store, connectionNode } = await createStoreWithConnection();
await expect(store.refreshConnectionTreeNode(connectionNode)).rejects.toThrow("locked");
expect(listDatabases).not.toHaveBeenCalled();
await expect(store.refreshConnectionTreeNode(connectionNode)).resolves.toBeUndefined();
expect(listDatabases).toHaveBeenCalledTimes(1);
});
it("encodes connection ids in the invalidated object cache prefixes", async () => {
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
installConnectionApiMocks({ deleteSchemaCachePrefix });
const { store, connectionNode } = await createStoreWithConnection("pg:1");
await store.refreshConnectionTreeNode(connectionNode);
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).sort()).toEqual(["object-ddl:v1:pg%3A1:", "object-meta:v1:pg%3A1:"]);
});
it("keeps non-connection node refresh best-effort when the object deletion fails", async () => {
const deleteSchemaCachePrefix = vi.fn((prefix: string) => (prefix.startsWith("object-") ? Promise.reject(new Error("locked")) : Promise.resolve()));
const listSchemaInfos = vi.fn().mockResolvedValue([{ name: "public", comment: null }]);
installConnectionApiMocks({ deleteSchemaCachePrefix, listSchemaInfos });
const { store } = await createStoreWithConnection();
const database = databaseNode();
store.treeNodes[0]!.children = [database];
await expect(store.refreshTreeNode(database)).resolves.toBeUndefined();
// The tree still reloads even though the object-cache deletion failed.
expect(listSchemaInfos).toHaveBeenCalledTimes(1);
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).sort()).toEqual(["object-ddl:v1:pg-1:app:", "object-meta:v1:pg-1:app:"]);
});
it("does not fetch object DDL or column metadata during a connection refresh", async () => {
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
const getTableDisplayDdl = vi.fn();
const getColumns = vi.fn();
installConnectionApiMocks({ deleteSchemaCachePrefix, getTableDisplayDdl, getColumns });
const { store, connectionNode } = await createStoreWithConnection();
await store.refreshConnectionTreeNode(connectionNode);
expect(getTableDisplayDdl).not.toHaveBeenCalled();
expect(getColumns).not.toHaveBeenCalled();
});
it("routes non-connection nodes to the regular refresh path", async () => {
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
installConnectionApiMocks({ deleteSchemaCachePrefix });
const { store } = await createStoreWithConnection();
const database = databaseNode();
store.treeNodes[0]!.children = [database];
await expect(store.refreshConnectionTreeNode(database)).resolves.toBeUndefined();
expect(objectCacheDeletePrefixes(deleteSchemaCachePrefix).sort()).toEqual(["object-ddl:v1:pg-1:app:", "object-meta:v1:pg-1:app:"]);
});
});
+20 -5
View File
@@ -2272,7 +2272,7 @@ export const useConnectionStore = defineStore("connection", () => {
});
}
function invalidateMetadataCachesForNode(node: TreeNode) {
function invalidateMetadataCachesForNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean }) {
if (!node.connectionId) return;
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
const match = {
@@ -2282,7 +2282,9 @@ export const useConnectionStore = defineStore("connection", () => {
tableName,
};
invalidateMetadataCaches(match);
void invalidateObjectDdlCache(match);
// Connection-node refresh already awaited a strict object-cache invalidation;
// skip the fire-and-forget duplicate so no late async deletion trails it.
if (!options?.skipObjectCacheInvalidation) void invalidateObjectDdlCache(match);
}
function invalidateMetadataCache(connectionId: string, database?: string, schema?: string, tableName?: string) {
@@ -7054,9 +7056,9 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function refreshTreeNode(node: TreeNode) {
async function refreshTreeNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean }) {
invalidateCompletionCachesForNode(node);
invalidateMetadataCachesForNode(node);
invalidateMetadataCachesForNode(node, options);
if (objectTypesForGroupNode(node.type)) {
clearLoadedChildrenCache(node.id, { deletePersisted: false });
await loadObjectGroupChildren(node, { force: true });
@@ -7067,7 +7069,7 @@ export const useConnectionStore = defineStore("connection", () => {
const parentId = objectGroupRefreshParentId(node);
const parentNode = parentId ? findNode(treeNodes.value, parentId) : null;
if (parentNode) {
await refreshTreeNode(parentNode);
await refreshTreeNode(parentNode, options);
return;
}
@@ -7119,6 +7121,18 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
/**
* Connection-node refresh: fully invalidate the connection's object caches
* (DDL + object metadata, memory and persisted) and surface deletion
* failures before the tree reload runs. Tree-reload failures propagate
* unmarked and keep the original connect-failure handling.
*/
async function refreshConnectionTreeNode(node: TreeNode): Promise<void> {
if (node.type !== "connection" || !node.connectionId) return refreshTreeNode(node);
await invalidateObjectDdlCache({ connectionId: node.connectionId }, { strict: true });
await refreshTreeNode(node, { skipObjectCacheInvalidation: true });
}
async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) {
invalidateMetadataCachesForNode(node);
if (objectTypesForGroupNode(node.type)) {
@@ -9223,6 +9237,7 @@ export const useConnectionStore = defineStore("connection", () => {
collapseAllTreeNodes,
refreshSidebarObjectPagination,
refreshTreeNode,
refreshConnectionTreeNode,
refreshDatabaseTreeNode,
refreshObjectListTreeNode,
connectedIds,