fix(metadata): await object cache invalidation on connection refresh
This commit is contained in:
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:"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user