fix: index many-to-many query invalidation
This commit is contained in:
@@ -6,38 +6,81 @@ import type {
|
||||
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";
|
||||
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 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 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(() => ({
|
||||
ok: true as const,
|
||||
transport: "BROADCAST" as const,
|
||||
}));
|
||||
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",
|
||||
@@ -85,61 +128,88 @@ function createClient(): QueryClient {
|
||||
}
|
||||
|
||||
describe("TanStack cross-context cache coordinator", () => {
|
||||
it("maps a local opaque topic to one namespace and publishes no query key", async () => {
|
||||
it("invalidates every real V2 key connected to one local topic and publishes only topic identity", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "list"], ["a"]);
|
||||
client.setQueryData(["resource-b", 1, "list"], ["b"]);
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
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,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
};
|
||||
const coordinator = createTanStackCacheCoordinator(dependencies);
|
||||
|
||||
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({
|
||||
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,
|
||||
});
|
||||
expect(JSON.stringify(harness.publish.mock.calls)).not.toContain(
|
||||
"resource-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies a remote hint without publishing an echo", async () => {
|
||||
it("invalidates every real V2 key connected to one remote topic without echoing it", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "detail", "opaque"], {
|
||||
value: true,
|
||||
});
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
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,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
};
|
||||
const coordinator = createTanStackCacheCoordinator(dependencies);
|
||||
|
||||
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();
|
||||
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 () => {
|
||||
@@ -149,7 +219,8 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
const lease = coordinator.beginMutation([TOPIC_A]);
|
||||
@@ -160,32 +231,10 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
|
||||
await lease.release();
|
||||
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(invalidate).toHaveBeenCalledTimes(2);
|
||||
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();
|
||||
@@ -202,7 +251,8 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
@@ -215,7 +265,7 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
finishCancellation?.();
|
||||
await reset;
|
||||
expect(client.getQueryData(["resource-a", 1, "list"])).toBeUndefined();
|
||||
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledTimes(2));
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -228,7 +278,8 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
const clear = vi.spyOn(client, "clear");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
});
|
||||
|
||||
await expect(coordinator.resetLocal()).rejects.toThrow(
|
||||
@@ -244,7 +295,12 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
const harness = crossContextHarness();
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: createClient(),
|
||||
queryRegistry: Object.freeze({}),
|
||||
invalidationIndex: indexInvalidationRegistry({
|
||||
topics: [],
|
||||
namespaces: [],
|
||||
edges: [],
|
||||
}),
|
||||
topicVersions: new Map(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
@@ -257,7 +313,8 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
it("rejects an unregistered topic before opening a mutation lease", () => {
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: createClient(),
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
|
||||
Reference in New Issue
Block a user