Files
clean-architecture-frontend…/tests/unit/tanstack-cache-coordinator.test.ts
T

329 lines
11 KiB
TypeScript

import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import type {
BrowserCrossContextInvalidation,
CrossContextInvalidationDelivery,
} from "../../src/adapters/cross-context-invalidation/index.ts";
import { createTanStackCacheCoordinator } from "../../src/adapters/query-cache/tanstack-cache-coordinator.ts";
import {
defineQueryInvalidationTopic,
indexInvalidationRegistry,
} from "../../src/contracts/query-invalidation.ts";
import {
createRuntimeIdentityRegistry,
defineQueryNamespaceIdentity,
type QueryNamespaceIdentity,
} from "../../src/contracts/query-keys.ts";
import { bindQuery } from "../../src/contracts/server-state.ts";
import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts";
const TOPIC_A = defineQueryInvalidationTopic("qinv.topic-a");
const TOPIC_B = defineQueryInvalidationTopic("qinv.topic-b");
const NAMESPACE_A = defineQueryNamespaceIdentity("resource-a", 1);
const NAMESPACE_B = defineQueryNamespaceIdentity("resource-b", 1);
const NAMESPACE_C = defineQueryNamespaceIdentity("resource-c", 1);
function invalidationIndex() {
return indexInvalidationRegistry({
topics: [TOPIC_A, TOPIC_B],
namespaces: [NAMESPACE_A, NAMESPACE_B, NAMESPACE_C],
edges: [
{ topicId: TOPIC_A, namespace: NAMESPACE_A },
{ topicId: TOPIC_A, namespace: NAMESPACE_B },
{ topicId: TOPIC_B, namespace: NAMESPACE_B },
{ topicId: TOPIC_B, namespace: NAMESPACE_C },
],
});
}
function topicVersions() {
return new Map([
[TOPIC_A, 1],
[TOPIC_B, 1],
]);
}
function realBoundQueryKey(namespace: QueryNamespaceIdentity) {
const scope: CacheScopeSnapshot = {
generation: 1,
fingerprint: "scope-fingerprint-0001",
identities: createRuntimeIdentityRegistry({
tokenFactory: () => `identity-token-${namespace.namespaceId}`,
}),
signal: new AbortController().signal,
isCurrent: () => true,
};
return bindQuery(
{
definitionId: `${namespace.namespaceId}-query-v1`,
definitionVersion: 1,
owner: "platform-test",
namespace: namespace.namespaceId,
namespaceVersion: namespace.namespaceVersion,
operationId: `GET_${namespace.namespaceId.toUpperCase()}`,
profileId: "DETAIL_STANDARD",
measureResult: () => ({ itemCount: 1, estimatedBytes: 8 }),
execute: async () => ({ ok: true as const, value: namespace.namespaceId }),
},
{ selected: namespace.namespaceId },
scope,
).queryKey;
}
function crossContextHarness() {
let listener:
| ((delivery: CrossContextInvalidationDelivery) => void)
| undefined;
const publish = vi.fn(
(_event: { topic: string; topicVersion: number }) => ({
ok: true as const,
transport: "BROADCAST" as const,
}),
);
const close = vi.fn();
const transport: BrowserCrossContextInvalidation = {
getStatus: () => "ACTIVE_BROADCAST",
publish,
subscribe(next) {
listener = next;
return () => {
listener = undefined;
};
},
close,
};
return Object.freeze({
transport,
publish,
close,
deliver(topic: string, ordering: "NEXT" | "GAP" = "NEXT") {
listener?.({
ordering,
transport: "BROADCAST",
event: {
protocolVersion: 1,
eventId: `event-${topic}-${ordering}`,
sourceId: "remote-source",
sourceEpoch: "remote-epoch",
sequence: 1,
cacheEpoch: "cache-epoch",
topic,
topicVersion: 1,
emittedAt: 1,
expiresAt: 60_001,
},
});
},
});
}
function createClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity, gcTime: Infinity },
mutations: { retry: false },
},
});
}
describe("TanStack cross-context cache coordinator", () => {
it("invalidates every real V2 key connected to one local topic and publishes only topic identity", async () => {
const client = createClient();
const harness = crossContextHarness();
const keyA = realBoundQueryKey(NAMESPACE_A);
const keyB = realBoundQueryKey(NAMESPACE_B);
const unrelatedKey = realBoundQueryKey(NAMESPACE_C);
client.setQueryData(keyA, ["a"]);
client.setQueryData(keyB, ["b"]);
client.setQueryData(unrelatedKey, ["c"]);
const dependencies = {
queryClient: client,
invalidationIndex: invalidationIndex(),
topicVersions: topicVersions(),
crossContext: harness.transport,
};
const coordinator = createTanStackCacheCoordinator(dependencies);
await coordinator.invalidate([TOPIC_A]);
expect(client.getQueryState(keyA)?.isInvalidated).toBe(true);
expect(client.getQueryState(keyB)?.isInvalidated).toBe(true);
expect(client.getQueryState(unrelatedKey)?.isInvalidated).toBe(false);
expect(harness.publish).toHaveBeenCalledOnce();
expect(harness.publish.mock.calls[0]?.[0]).toEqual({
topic: TOPIC_A,
topicVersion: 1,
});
});
it("invalidates every real V2 key connected to one remote topic without echoing it", async () => {
const client = createClient();
const harness = crossContextHarness();
const keyA = realBoundQueryKey(NAMESPACE_A);
const keyB = realBoundQueryKey(NAMESPACE_B);
const unrelatedKey = realBoundQueryKey(NAMESPACE_C);
client.setQueryData(keyA, ["a"]);
client.setQueryData(keyB, ["b"]);
client.setQueryData(unrelatedKey, ["c"]);
const dependencies = {
queryClient: client,
invalidationIndex: invalidationIndex(),
topicVersions: topicVersions(),
crossContext: harness.transport,
};
const coordinator = createTanStackCacheCoordinator(dependencies);
harness.deliver(TOPIC_A);
await vi.waitFor(() => {
expect(client.getQueryState(keyA)?.isInvalidated).toBe(true);
expect(client.getQueryState(keyB)?.isInvalidated).toBe(true);
});
expect(client.getQueryState(unrelatedKey)?.isInvalidated).toBe(false);
expect(harness.publish).not.toHaveBeenCalled();
});
it("invalidates each real V2 namespace once when a sequence gap spans overlapping topics", async () => {
const client = createClient();
const harness = crossContextHarness();
const keyA = realBoundQueryKey(NAMESPACE_A);
const keyB = realBoundQueryKey(NAMESPACE_B);
const keyC = realBoundQueryKey(NAMESPACE_C);
client.setQueryData(keyA, ["a"]);
client.setQueryData(keyB, ["b"]);
client.setQueryData(keyC, ["c"]);
const invalidate = vi.spyOn(client, "invalidateQueries");
const dependencies = {
queryClient: client,
invalidationIndex: invalidationIndex(),
topicVersions: topicVersions(),
crossContext: harness.transport,
};
createTanStackCacheCoordinator(dependencies);
harness.deliver(TOPIC_A, "GAP");
await vi.waitFor(() => {
expect(client.getQueryState(keyA)?.isInvalidated).toBe(true);
expect(client.getQueryState(keyB)?.isInvalidated).toBe(true);
expect(client.getQueryState(keyC)?.isInvalidated).toBe(true);
});
expect(invalidate).toHaveBeenCalledTimes(3);
});
it("coalesces remote hints while a local mutation lease is held", async () => {
const client = createClient();
const harness = crossContextHarness();
client.setQueryData(["resource-a", 1, "list"], ["a"]);
const invalidate = vi.spyOn(client, "invalidateQueries");
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
invalidationIndex: invalidationIndex(),
topicVersions: topicVersions(),
crossContext: harness.transport,
});
const lease = coordinator.beginMutation([TOPIC_A]);
harness.deliver(TOPIC_A);
harness.deliver(TOPIC_A);
await Promise.resolve();
expect(invalidate).not.toHaveBeenCalled();
await lease.release();
expect(invalidate).toHaveBeenCalledTimes(2);
expect(harness.publish).not.toHaveBeenCalled();
});
it("fences remote delivery until a local reset has cancelled and cleared the cache", async () => {
const client = createClient();
const harness = crossContextHarness();
client.setQueryData(["resource-a", 1, "list"], ["private-old-scope"]);
let finishCancellation: (() => void) | undefined;
const cancelQueries = vi
.spyOn(client, "cancelQueries")
.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishCancellation = resolve;
}),
);
const invalidate = vi.spyOn(client, "invalidateQueries");
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
invalidationIndex: invalidationIndex(),
topicVersions: topicVersions(),
crossContext: harness.transport,
});
const reset = coordinator.resetLocal();
await vi.waitFor(() => expect(cancelQueries).toHaveBeenCalledOnce());
harness.deliver(TOPIC_A);
await Promise.resolve();
expect(invalidate).not.toHaveBeenCalled();
finishCancellation?.();
await reset;
expect(client.getQueryData(["resource-a", 1, "list"])).toBeUndefined();
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledTimes(2));
expect(harness.publish).not.toHaveBeenCalled();
});
it("rejects a mandatory local reset when query cancellation fails", async () => {
const client = createClient();
client.setQueryData(["resource-a", 1, "list"], ["private-old-scope"]);
vi.spyOn(client, "cancelQueries").mockRejectedValue(
new Error("cancellation failed"),
);
const clear = vi.spyOn(client, "clear");
const coordinator = createTanStackCacheCoordinator({
queryClient: client,
invalidationIndex: invalidationIndex(),
topicVersions: topicVersions(),
});
await expect(coordinator.resetLocal()).rejects.toThrow(
"mandatory query cancellation failed",
);
expect(clear).toHaveBeenCalledOnce();
});
it("composes with no installed query topics", () => {
// §24.12: removing the reference feature leaves the common runtime intact.
// A template with no installed feature has zero invalidation topics, which
// is a legitimate state, not a configuration defect.
const harness = crossContextHarness();
const coordinator = createTanStackCacheCoordinator({
queryClient: createClient(),
invalidationIndex: indexInvalidationRegistry({
topics: [],
namespaces: [],
edges: [],
}),
topicVersions: new Map(),
crossContext: harness.transport,
});
// A remote hint for a topic this build does not install is ignored, not fatal.
harness.deliver("qinv.topic-a");
expect(() => coordinator.beginMutation([])).not.toThrow();
coordinator.dispose();
});
it("rejects an unregistered topic before opening a mutation lease", () => {
const coordinator = createTanStackCacheCoordinator({
queryClient: createClient(),
invalidationIndex: invalidationIndex(),
topicVersions: topicVersions(),
});
expect(() =>
coordinator.beginMutation([
defineQueryInvalidationTopic("unknown-topic"),
]),
).toThrow(
"Unregistered query invalidation topic",
);
});
});