272 lines
8.4 KiB
TypeScript
272 lines
8.4 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 } from "../../src/contracts/query-invalidation.ts";
|
|
|
|
const TOPIC_A = defineQueryInvalidationTopic("qinv.topic-a");
|
|
const TOPIC_B = defineQueryInvalidationTopic("qinv.topic-b");
|
|
|
|
function queryRegistry() {
|
|
return Object.freeze({
|
|
A: Object.freeze({
|
|
namespace: Object.freeze(["resource-a", 1] as const),
|
|
invalidationTopic: TOPIC_A,
|
|
crossContext: "invalidate-only" as const,
|
|
version: 1,
|
|
persistence: "disabled" as const,
|
|
}),
|
|
B: Object.freeze({
|
|
namespace: Object.freeze(["resource-b", 1] as const),
|
|
invalidationTopic: TOPIC_B,
|
|
crossContext: "invalidate-only" as const,
|
|
version: 1,
|
|
persistence: "disabled" as const,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function crossContextHarness() {
|
|
let listener:
|
|
| ((delivery: CrossContextInvalidationDelivery) => void)
|
|
| undefined;
|
|
const publish = vi.fn(() => ({
|
|
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("maps a local opaque topic to one namespace and publishes no query key", async () => {
|
|
const client = createClient();
|
|
const harness = crossContextHarness();
|
|
client.setQueryData(["resource-a", 1, "list"], ["a"]);
|
|
client.setQueryData(["resource-b", 1, "list"], ["b"]);
|
|
const coordinator = createTanStackCacheCoordinator({
|
|
queryClient: client,
|
|
queryRegistry: queryRegistry(),
|
|
crossContext: harness.transport,
|
|
});
|
|
|
|
await coordinator.invalidate([TOPIC_A]);
|
|
|
|
expect(
|
|
client.getQueryState(["resource-a", 1, "list"])?.isInvalidated,
|
|
).toBe(true);
|
|
expect(
|
|
client.getQueryState(["resource-b", 1, "list"])?.isInvalidated,
|
|
).toBe(false);
|
|
expect(harness.publish).toHaveBeenCalledWith({
|
|
topic: TOPIC_A,
|
|
topicVersion: 1,
|
|
});
|
|
expect(JSON.stringify(harness.publish.mock.calls)).not.toContain(
|
|
"resource-a",
|
|
);
|
|
});
|
|
|
|
it("applies a remote hint without publishing an echo", async () => {
|
|
const client = createClient();
|
|
const harness = crossContextHarness();
|
|
client.setQueryData(["resource-a", 1, "detail", "opaque"], {
|
|
value: true,
|
|
});
|
|
const coordinator = createTanStackCacheCoordinator({
|
|
queryClient: client,
|
|
queryRegistry: queryRegistry(),
|
|
crossContext: harness.transport,
|
|
});
|
|
|
|
harness.deliver(TOPIC_A);
|
|
await vi.waitFor(() =>
|
|
expect(
|
|
client.getQueryState([
|
|
"resource-a",
|
|
1,
|
|
"detail",
|
|
"opaque",
|
|
])?.isInvalidated,
|
|
).toBe(true),
|
|
);
|
|
expect(harness.publish).not.toHaveBeenCalled();
|
|
|
|
coordinator.dispose();
|
|
expect(harness.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
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,
|
|
queryRegistry: queryRegistry(),
|
|
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(1);
|
|
expect(harness.publish).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reconciles every registered namespace when a source sequence has a gap", async () => {
|
|
const client = createClient();
|
|
const harness = crossContextHarness();
|
|
client.setQueryData(["resource-a", 1, "list"], ["a"]);
|
|
client.setQueryData(["resource-b", 1, "list"], ["b"]);
|
|
const coordinator = createTanStackCacheCoordinator({
|
|
queryClient: client,
|
|
queryRegistry: queryRegistry(),
|
|
crossContext: harness.transport,
|
|
});
|
|
|
|
harness.deliver(TOPIC_A, "GAP");
|
|
await vi.waitFor(() => {
|
|
expect(
|
|
client.getQueryState(["resource-a", 1, "list"])?.isInvalidated,
|
|
).toBe(true);
|
|
expect(
|
|
client.getQueryState(["resource-b", 1, "list"])?.isInvalidated,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
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,
|
|
queryRegistry: queryRegistry(),
|
|
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).toHaveBeenCalledOnce());
|
|
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,
|
|
queryRegistry: queryRegistry(),
|
|
});
|
|
|
|
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(),
|
|
queryRegistry: Object.freeze({}),
|
|
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(),
|
|
queryRegistry: queryRegistry(),
|
|
});
|
|
|
|
expect(() =>
|
|
coordinator.beginMutation([
|
|
defineQueryInvalidationTopic("unknown-topic"),
|
|
]),
|
|
).toThrow(
|
|
"Unregistered query invalidation topic",
|
|
);
|
|
});
|
|
});
|