fix: index many-to-many query invalidation

This commit is contained in:
DongHyeonka
2026-08-01 23:17:09 +09:00
parent 853c2e3f30
commit 73a50426d6
18 changed files with 574 additions and 325 deletions
+5 -5
View File
@@ -223,12 +223,12 @@ describe("scope-bound query commit fence", () => {
it("binds the namespace-first V2 query key", () => {
const scope = scopeSnapshot();
const definition = {
definitionId: "reference-detail-v1",
definitionId: "resource-detail-v1",
definitionVersion: 1,
owner: "platform-test",
namespace: "reference-resource",
namespace: "resource",
namespaceVersion: 1,
operationId: "GET_REFERENCE_RESOURCE",
operationId: "GET_RESOURCE",
profileId: "DETAIL_STANDARD" as const,
measureResult: measureOne,
execute: async () => ({ ok: true as const, value: "value" }),
@@ -239,7 +239,7 @@ describe("scope-bound query commit fence", () => {
expect(bound.queryKey).toEqual([
"query",
2,
"reference-resource",
"resource",
1,
"scope-fingerprint-0001",
1,
@@ -247,7 +247,7 @@ describe("scope-bound query commit fence", () => {
]);
expect(bound.queryKey.slice(0, 4)).toEqual(
createQueryInvalidationPrefix(
defineQueryNamespaceIdentity("reference-resource", 1),
defineQueryNamespaceIdentity("resource", 1),
),
);
});
@@ -15,7 +15,7 @@ import {
import {
REFERENCE_FEATURE_CONTRACT,
REFERENCE_FEATURE_ID,
referenceQueryKeys,
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
import type { ReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.ts";
import { createTestApplication } from "../../helpers/create-test-application.ts";
@@ -47,7 +47,7 @@ describe("reference feature boundary contracts", () => {
});
});
it("round-trips one canonical filter through URL and query identity", () => {
it("round-trips one canonical filter through the URL codec", () => {
const filters = {
tags: ["open", "new"],
cursor: "a/b",
@@ -66,7 +66,6 @@ describe("reference feature boundary contracts", () => {
success: true,
data: { search: filters },
});
expect(referenceQueryKeys.list(filters).at(-1)).toEqual(filters);
});
it("rejects unknown search and malformed DTO before mapping", () => {
@@ -110,7 +109,7 @@ describe("reference feature boundary contracts", () => {
});
});
it("owns route, operation and query contributions in one removable contract", () => {
it("owns route and operation contributions in one removable contract", () => {
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
"REFERENCE_RESOURCE_LIST",
"REFERENCE_RESOURCE_DETAIL",
@@ -131,8 +130,29 @@ describe("reference feature boundary contracts", () => {
authProfileId: "REFERENCE_EXTERNAL_BEARER",
csrfProfileId: "NO_CSRF_BEARER",
});
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([
"REFERENCE_RESOURCE",
});
it("contributes an identity-based invalidation graph and an explicit wire version", () => {
expect(REFERENCE_FEATURE_CONTRACT.invalidation).toEqual({
topics: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
namespaces: [
{ namespaceId: "reference-resource", namespaceVersion: 1 },
],
edges: [
{
topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC,
namespace: {
namespaceId: "reference-resource",
namespaceVersion: 1,
},
},
],
});
expect(REFERENCE_FEATURE_CONTRACT.topicVersions).toEqual([
{
topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC,
topicVersion: 1,
},
]);
});
+14 -5
View File
@@ -4,6 +4,7 @@ import {
NAVIGATION_ROUTES,
ROUTE_REGISTRY,
} from "../../src/features/installed-feature-contracts.ts";
import type { RouteDefinition } from "../../src/contracts/routes.ts";
import {
createRedirectLoopGuard,
decideRouteAccess,
@@ -29,12 +30,20 @@ describe("installed route registry", () => {
});
it("treats every non-public route as explicitly session-required", () => {
expect(ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST.access).toBe(
"session-required",
);
const protectedRoutes = Object.values(
ROUTE_REGISTRY as Readonly<Record<string, RouteDefinition>>,
).filter((route) => route.access !== "public");
expect(
decideRouteAccess("REFERENCE_RESOURCE_LIST", "unauthenticated"),
).toEqual({ allowed: false, action: "show-sign-in" });
protectedRoutes.every((route) => route.access === "session-required"),
).toBe(true);
for (const route of protectedRoutes) {
expect(route.access).toBe("session-required");
expect(decideRouteAccess(route.routeId, "unauthenticated")).toEqual({
allowed: false,
action: "show-sign-in",
});
}
});
it("bounds automatic redirects by pair and maximum hops", () => {
+88 -12
View File
@@ -1,24 +1,30 @@
import { describe, expect, it } from "vitest";
import { indexInvalidationRegistry } from "../../src/contracts/query-invalidation.ts";
import {
defineQueryInvalidationTopic,
indexInvalidationRegistry,
indexInvalidationTopicVersions,
type InvalidationRegistry,
} from "../../src/contracts/query-invalidation.ts";
import {
createBoundQueryKey,
createQueryInvalidationPrefix,
defineQueryNamespaceIdentity,
queryNamespaceIdentityKey,
type QueryNamespaceIdentity,
} from "../../src/contracts/query-keys.ts";
describe("query namespace identity", () => {
it("uses a canonical JSON tuple as the namespace identity key", () => {
const namespace = defineQueryNamespaceIdentity("reference-resource", 1);
const namespace = defineQueryNamespaceIdentity("orders", 1);
expect(queryNamespaceIdentityKey(namespace)).toBe(
'["reference-resource",1]',
'["orders",1]',
);
});
it("creates one namespace-first prefix for invalidation and bound keys", () => {
const namespace = defineQueryNamespaceIdentity("reference-resource", 1);
const namespace = defineQueryNamespaceIdentity("orders", 1);
const prefix = createQueryInvalidationPrefix(namespace);
const key = createBoundQueryKey(
namespace,
@@ -27,11 +33,11 @@ describe("query namespace identity", () => {
"identity-token-0001",
);
expect(prefix).toEqual(["query", 2, "reference-resource", 1]);
expect(prefix).toEqual(["query", 2, "orders", 1]);
expect(key).toEqual([
"query",
2,
"reference-resource",
"orders",
1,
"scope-fingerprint-0001",
3,
@@ -57,13 +63,13 @@ describe("query namespace identity", () => {
"rejects the non-positive-safe namespace version %s",
(namespaceVersion) => {
expect(() =>
defineQueryNamespaceIdentity("reference-resource", namespaceVersion),
defineQueryNamespaceIdentity("orders", namespaceVersion),
).toThrow(/namespace identity is invalid/u);
},
);
it("rejects a non-positive definition version before creating a bound key", () => {
const namespace = defineQueryNamespaceIdentity("reference-resource", 1);
const namespace = defineQueryNamespaceIdentity("orders", 1);
expect(() =>
createBoundQueryKey(
@@ -77,21 +83,91 @@ describe("query namespace identity", () => {
});
describe("query invalidation registry", () => {
const topic = defineQueryInvalidationTopic("qinv.orders.changed");
const orders = defineQueryNamespaceIdentity("orders", 1);
const summaries = defineQueryNamespaceIdentity("order-summaries", 2);
it("indexes every namespace identity connected to one topic", () => {
const registry: InvalidationRegistry = {
topics: [topic],
namespaces: [orders, summaries],
edges: [
{ topicId: topic, namespace: orders },
{ topicId: topic, namespace: summaries },
],
};
const index = indexInvalidationRegistry(registry);
expect(index.namespacesForTopic.get(topic)).toEqual([orders, summaries]);
expect(index.topicsForNamespace.get('["orders",1]')).toEqual([topic]);
expect(index.topicsForNamespace.get('["order-summaries",2]')).toEqual([
topic,
]);
});
it("rejects duplicate namespace identities even when they are separate objects", () => {
const duplicate = defineQueryNamespaceIdentity("orders", 1);
expect(() =>
indexInvalidationRegistry({
topics: [topic],
namespaces: [orders, duplicate],
edges: [{ topicId: topic, namespace: orders }],
}),
).toThrow(/Duplicate invalidation namespace/u);
});
it("projects one bounded transport version for every registered topic", () => {
const registry: InvalidationRegistry = {
topics: [topic],
namespaces: [orders],
edges: [{ topicId: topic, namespace: orders }],
};
const versions = indexInvalidationTopicVersions(registry, [
{ topicId: topic, topicVersion: 1 },
]);
expect([...versions]).toEqual([[topic, 1]]);
expect(() =>
indexInvalidationTopicVersions(registry, [
{ topicId: topic, topicVersion: 1 },
{ topicId: topic, topicVersion: 2 },
]),
).toThrow(/topic version registry/u);
expect(() => indexInvalidationTopicVersions(registry, [])).toThrow(
/topic version registry/u,
);
});
it.each([
{
label: "topic",
registry: {
topics: ["orders\u0000private"],
namespaces: ["orders"],
edges: [{ topicId: "orders\u0000private", namespace: "orders" }],
namespaces: [orders],
edges: [{ topicId: "orders\u0000private", namespace: orders }],
},
},
{
label: "namespace",
registry: {
topics: ["orders"],
namespaces: ["orders\u001fprivate"],
edges: [{ topicId: "orders", namespace: "orders\u001fprivate" }],
namespaces: [
{
namespaceId: "orders\u001fprivate",
namespaceVersion: 1,
} as QueryNamespaceIdentity,
],
edges: [
{
topicId: "orders",
namespace: {
namespaceId: "orders\u001fprivate",
namespaceVersion: 1,
} as QueryNamespaceIdentity,
},
],
},
},
])("rejects control characters in a registry $label", ({ registry }) => {
+3 -3
View File
@@ -9,14 +9,14 @@ type RegistryDefinition = Readonly<{
}>;
describe("registry governance manifest", () => {
it("declares ten typed, single-owner executable registries", async () => {
it("declares nine typed, single-owner executable registries", async () => {
const governance = JSON.parse(
await readFile("config/contracts/registry-governance.json", "utf8"),
);
const registries = governance.registries as RegistryDefinition[];
expect(governance.registries).toHaveLength(10);
expect(governance.registries).toHaveLength(9);
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
10,
9,
);
expect(registries.every((entry) => entry.owner)).toBe(true);
expect(
+53 -3
View File
@@ -4,8 +4,14 @@ import {
createRuntimeAdapters,
createRuntimeHttpClient,
} from "../../src/bootstrap/runtime-adapters.ts";
import { QUERY_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
import { REFERENCE_FEATURE_ID } from "../../src/features/reference-feature/contracts/reference-feature-contract.ts";
import { INVALIDATION_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
import {
REFERENCE_FEATURE_ID,
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
} from "../../src/features/reference-feature/contracts/reference-feature-contract.ts";
import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts";
import { bindQuery } from "../../src/contracts/server-state.ts";
import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts";
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts";
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
@@ -54,6 +60,33 @@ const release: Release = {
routeChunks: { "route-home": "assets/home.js" },
};
function referenceBoundQueryKey() {
const scope: CacheScopeSnapshot = {
generation: 1,
fingerprint: "runtime-scope-fingerprint-0001",
identities: createRuntimeIdentityRegistry({
tokenFactory: () => "runtime-identity-token-0001",
}),
signal: new AbortController().signal,
isCurrent: () => true,
};
return bindQuery(
{
definitionId: "reference-resource-runtime-test-v1",
definitionVersion: 1,
owner: REFERENCE_FEATURE_ID,
namespace: "reference-resource",
namespaceVersion: 1,
operationId: "GET_REFERENCE_RESOURCE",
profileId: "DETAIL_STANDARD",
measureResult: () => ({ itemCount: 1, estimatedBytes: 8 }),
execute: async () => ({ ok: true as const, value: "reference-1" }),
},
"reference-1",
scope,
).queryKey;
}
describe("runtime adapter composition", () => {
it("constructs the local demo seam and infrastructure adapters", async () => {
const adapters = await createRuntimeAdapters({
@@ -79,6 +112,23 @@ describe("runtime adapter composition", () => {
adapters.infrastructure.dispose();
});
it("invalidates a real bound query through the installed production graph", async () => {
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
const queryKey = referenceBoundQueryKey();
adapters.infrastructure.queryClient.setQueryData(queryKey, {
resourceId: "reference-1",
});
await adapters.infrastructure.queryInvalidation.invalidate([
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
]);
expect(
adapters.infrastructure.queryClient.getQueryState(queryKey)?.isInvalidated,
).toBe(true);
adapters.infrastructure.dispose();
});
it("executes installed feature HTTP through the composed contract registry", async () => {
const fetcher = vi.fn(async () =>
Response.json([{ id: "reference-1", name: "Direct contract payload" }]),
@@ -132,7 +182,7 @@ describe("runtime adapter composition", () => {
previousCoordinator,
);
const topic = Object.values(QUERY_REGISTRY)[0]?.invalidationTopic;
const topic = INVALIDATION_REGISTRY.topics[0];
if (!topic) throw new Error("expected an installed invalidation topic");
await previousCoordinator.invalidate([topic]);
expect(invalidatePrevious).not.toHaveBeenCalled();
+143 -86
View File
@@ -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(() =>