diff --git a/config/contracts/registry-change-evidence.json b/config/contracts/registry-change-evidence.json index 130db93..b039daa 100644 --- a/config/contracts/registry-change-evidence.json +++ b/config/contracts/registry-change-evidence.json @@ -9,6 +9,14 @@ "rollback": "Restore the V1 writer in scripts/generate-build-manifest.ts and the scalar key in public/config.json; the V1 reader is still present.", "owner": "frontend-platform" }, + { + "changeId": "FE-REG-QUERY:*:*:removed", + "versionBump": "Query invalidation composition moves from the legacy flat query registry to the bounded many-to-many invalidation graph.", + "migration": "Installed feature contracts now contribute topics, namespace identities, edges, and separate wire versions; bootstrap validates and indexes those contributions before constructing coordinators.", + "compatibilityWindow": "Cross-context envelopes remain opaque topic/version pairs and release cache epochs isolate mixed releases; no query keys or cached values cross contexts.", + "rollback": "Restore the flat QUERY_REGISTRY composition and its coordinator adapter together with the prior governance entry.", + "owner": "frontend-platform" + }, { "changeId": "FE-REG-QUERY:$contract:allowedValues:contract-field-changed", "versionBump": "Cross-context invalidation wire protocol starts at version 1.", diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index 29232ed..9659ea0 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -379,52 +379,6 @@ ], "breakingFields": ["kind", "userMessageKey", "action", "telemetryEvent"] }, - { - "registryId": "FE-REG-QUERY", - "path": "src/features/installed-feature-contracts.ts", - "exportName": "QUERY_REGISTRY", - "owner": "feature-frontend-server-state-caching-contract", - "requiredFields": [ - "namespace", - "serialization", - "identity", - "invalidation", - "invalidationTopic", - "crossContext", - "version", - "persistence" - ], - "fieldTypes": { - "namespace": "array", - "serialization": "string", - "identity": "string", - "invalidation": "string", - "invalidationTopic": "string", - "crossContext": "string", - "version": "integer", - "persistence": "string" - }, - "uniqueFields": ["namespace", "invalidationTopic"], - "allowedValues": { - "crossContext": ["invalidate-only"], - "persistence": ["disabled"] - }, - "consumers": [ - { - "path": "src/features/reference-feature/contracts/reference-feature-contract.ts", - "token": "referenceQueryKeys" - } - ], - "breakingFields": [ - "namespace", - "serialization", - "identity", - "invalidationTopic", - "crossContext", - "version", - "persistence" - ] - }, { "registryId": "FE-REG-TELEMETRY", "path": "src/contracts/telemetry.ts", diff --git a/schemas/artifacts/registry-snapshot.schema.json b/schemas/artifacts/registry-snapshot.schema.json index e62f38f..30c2a93 100644 --- a/schemas/artifacts/registry-snapshot.schema.json +++ b/schemas/artifacts/registry-snapshot.schema.json @@ -35,8 +35,8 @@ "failures": { "type": "array", "maxItems": 0 }, "registries": { "type": "array", - "minItems": 10, - "maxItems": 10, + "minItems": 9, + "maxItems": 9, "items": { "type": "object", "required": [ diff --git a/scripts/test-performance.ts b/scripts/test-performance.ts index 68c4c3f..2e8c144 100644 --- a/scripts/test-performance.ts +++ b/scripts/test-performance.ts @@ -82,13 +82,14 @@ try { }).observe({ type: "layout-shift", buffered: true }); }); await page.goto(baseUrl, { waitUntil: "networkidle" }); - const targetLabel = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST.navigationLabel; + const target = ROUTE_REGISTRY.EXAMPLES_PLATFORM; + const targetLabel = target.navigationLabel; if (!targetLabel) { throw new Error("Performance route must be present in navigation."); } const interactionStarted = performance.now(); await page.getByRole("link", { name: targetLabel }).click(); - await page.getByRole("heading", { name: "세션이 필요합니다." }).waitFor(); + await page.getByRole("heading", { name: target.title }).waitFor(); const namedInteractionMs = performance.now() - interactionStarted; const paint = await page.evaluate( () => (window as ContractPerformanceWindow).__contractPerformance, diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 904dcc3..3b50a2b 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -22,12 +22,16 @@ const featureOwnedPaths = [ "tests/mocks", "tests/fixtures/typecheck/invalid-feature-input.ts", "tests/fixtures/typecheck/invalid-reference-operation.ts", + "tests/unit/external-contract-runtime.test.ts", + "tests/unit/http-execution-v3.test.ts", + "tests/unit/runtime-adapters.test.ts", ]; const copyTargets = [ "src", "tests", "recipes", "scripts", + "schemas", "config", "public", ".storybook", @@ -60,7 +64,12 @@ export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]) export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY; export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT; export const API_OPERATIONS = Object.freeze({}); -export const QUERY_REGISTRY = Object.freeze({}); +export const INVALIDATION_REGISTRY = Object.freeze({ + topics: Object.freeze([]), + namespaces: Object.freeze([]), + edges: Object.freeze([]), +}); +export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]); export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY; export const NAVIGATION_ROUTES = Object.freeze( Object.values(ROUTE_REGISTRY) @@ -88,10 +97,7 @@ export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS; export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME; `; -const emptyAdapters = `type FeatureContext = Readonly<{ - createHttpClient(contract: Readonly>): unknown; -}>; -export function createInstalledFeatureInputs(_context: FeatureContext) { +const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) { void _context; return Object.freeze({}); } diff --git a/src/adapters/query-cache/tanstack-cache-coordinator.ts b/src/adapters/query-cache/tanstack-cache-coordinator.ts index 2c00dd8..8855fa5 100644 --- a/src/adapters/query-cache/tanstack-cache-coordinator.ts +++ b/src/adapters/query-cache/tanstack-cache-coordinator.ts @@ -2,42 +2,30 @@ import type { QueryClient } from "@tanstack/react-query"; import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts"; import type { + InvalidationRegistryIndex, QueryInvalidationCoordinator, QueryInvalidationTopic, QueryMutationLease, } from "../../contracts/query-invalidation.ts"; -import { isCacheInvalidationTopic } from "../../contracts/cache-invalidation.ts"; +import { INVALIDATION_REGISTRY_BOUNDS } from "../../contracts/query-invalidation.ts"; +import { + createQueryInvalidationPrefix, + queryNamespaceIdentityKey, + type QueryNamespaceIdentity, +} from "../../contracts/query-keys.ts"; import type { BrowserCrossContextInvalidation, CrossContextInvalidationDelivery, } from "../cross-context-invalidation/index.ts"; -export type InstalledQueryInvalidationDefinition = Readonly<{ - namespace: readonly unknown[]; - invalidationTopic: QueryInvalidationTopic; - crossContext: "invalidate-only"; - version: number; - persistence: "disabled"; -}>; - export type TanStackCacheCoordinatorDependencies = Readonly<{ queryClient: QueryClient; - queryRegistry: Readonly< - Record - >; + invalidationIndex: InvalidationRegistryIndex; + topicVersions: ReadonlyMap; crossContext?: BrowserCrossContextInvalidation; diagnostics?: DiagnosticsPort; }>; -type RuntimeDefinition = Readonly<{ - namespace: readonly unknown[]; - topic: QueryInvalidationTopic; - version: number; -}>; - -const MAX_NAMESPACE_PARTS = 8; -const MAX_NAMESPACE_BYTES = 1_024; - /** * Joins registry-owned invalidation topics to TanStack Query without putting a * query key or cached value on the cross-context wire. @@ -45,7 +33,10 @@ const MAX_NAMESPACE_BYTES = 1_024; export function createTanStackCacheCoordinator( dependencies: TanStackCacheCoordinatorDependencies, ): QueryInvalidationCoordinator { - const definitions = buildDefinitions(dependencies.queryRegistry); + validateConfiguration( + dependencies.invalidationIndex, + dependencies.topicVersions, + ); const mutationLeases = new Map(); const pendingRemote = new Set(); let disposed = false; @@ -58,8 +49,10 @@ export function createTanStackCacheCoordinator( receiveRemote(delivery); }); - function definition(topic: QueryInvalidationTopic): RuntimeDefinition { - const selected = definitions.get(topic); + function namespacesFor( + topic: QueryInvalidationTopic, + ): readonly QueryNamespaceIdentity[] { + const selected = dependencies.invalidationIndex.namespacesForTopic.get(topic); if (!selected) { throw new TypeError("Unregistered query invalidation topic."); } @@ -67,7 +60,7 @@ export function createTanStackCacheCoordinator( } async function invalidateLocal( - topic: QueryInvalidationTopic, + topics: readonly QueryInvalidationTopic[], expectedGeneration = lifecycleGeneration, ): Promise { if ( @@ -77,15 +70,22 @@ export function createTanStackCacheCoordinator( ) { return; } - const selected = definition(topic); - try { - await dependencies.queryClient.invalidateQueries({ - queryKey: selected.namespace, - exact: false, - refetchType: "active", - }); - } catch { - report("invalidate"); + const namespaces = new Map(); + for (const topic of topics) { + for (const namespace of namespacesFor(topic)) { + namespaces.set(queryNamespaceIdentityKey(namespace), namespace); + } + } + for (const namespace of namespaces.values()) { + try { + await dependencies.queryClient.invalidateQueries({ + queryKey: createQueryInvalidationPrefix(namespace), + exact: false, + refetchType: "active", + }); + } catch { + report("invalidate"); + } } } @@ -93,19 +93,23 @@ export function createTanStackCacheCoordinator( delivery: CrossContextInvalidationDelivery, ): void { if (disposed) return; - const selected = definitions.get(delivery.event.topic); - if (!selected) { + if ( + !dependencies.invalidationIndex.namespacesForTopic.has( + delivery.event.topic, + ) + ) { report("unknown-topic"); return; } + const selectedTopic = delivery.event.topic as QueryInvalidationTopic; if (delivery.ordering === "GAP") { - for (const candidate of definitions.values()) { - pendingRemote.add(candidate.topic); + for (const topic of dependencies.invalidationIndex.namespacesForTopic.keys()) { + pendingRemote.add(topic as QueryInvalidationTopic); } report("sequence-gap"); } else { - pendingRemote.add(selected.topic); + pendingRemote.add(selectedTopic); } if (!resetting) void flushRemote(); } @@ -128,8 +132,8 @@ export function createTanStackCacheCoordinator( if (ready.length === 0) return; for (const topic of ready) { pendingRemote.delete(topic); - await invalidateLocal(topic, expectedGeneration); } + await invalidateLocal(ready, expectedGeneration); } }) .catch(() => { @@ -154,7 +158,7 @@ export function createTanStackCacheCoordinator( topics: readonly QueryInvalidationTopic[], ): readonly QueryInvalidationTopic[] { const unique = [...new Set(topics)]; - for (const topic of unique) definition(topic); + for (const topic of unique) namespacesFor(topic); return unique; } @@ -179,12 +183,15 @@ export function createTanStackCacheCoordinator( ): Promise { if (disposed) return; const selectedTopics = uniqueTopics(topics); + await invalidateLocal(selectedTopics); for (const topic of selectedTopics) { - const selected = definition(topic); - await invalidateLocal(topic); + const topicVersion = dependencies.topicVersions.get(topic); + if (topicVersion === undefined) { + throw new TypeError("Unregistered query invalidation topic."); + } const published = dependencies.crossContext?.publish({ topic, - topicVersion: selected.version, + topicVersion, }); if (published && !published.ok) { report("cross-context-publish"); @@ -276,57 +283,37 @@ export function createTanStackCacheCoordinator( }); } -function buildDefinitions( - registry: Readonly< - Record - >, -): ReadonlyMap { - const definitions = new Map(); - for (const candidate of Object.values(registry)) { +function validateConfiguration( + index: InvalidationRegistryIndex, + topicVersions: ReadonlyMap, +): void { + if ( + index.namespacesForTopic.size > INVALIDATION_REGISTRY_BOUNDS.maxTopics || + topicVersions.size !== index.namespacesForTopic.size + ) { + throw new TypeError("Query invalidation registry is invalid."); + } + for (const [topic, namespaces] of index.namespacesForTopic) { + const version = topicVersions.get(topic); if ( - !candidate || - !isCacheInvalidationTopic(candidate.invalidationTopic) || - candidate.crossContext !== "invalidate-only" || - candidate.persistence !== "disabled" || - !Number.isSafeInteger(candidate.version) || - candidate.version < 1 || - !isSafeNamespace(candidate.namespace) || - definitions.has(candidate.invalidationTopic) + namespaces.length < 1 || + !Number.isSafeInteger(version) || + (version ?? 0) < 1 ) { throw new TypeError("Query invalidation registry is invalid."); } - definitions.set( - candidate.invalidationTopic, - Object.freeze({ - namespace: Object.freeze(structuredClone(candidate.namespace)), - topic: candidate.invalidationTopic, - version: candidate.version, - }), - ); + const namespaceKeys = new Set(); + for (const namespace of namespaces) { + const key = queryNamespaceIdentityKey(namespace); + if (namespaceKeys.has(key)) { + throw new TypeError("Query invalidation registry is invalid."); + } + namespaceKeys.add(key); + } } - // An empty registry is a legitimate state: a template with no installed - // feature has no invalidation topic. Every per-entry rule above still - // applies, and an unregistered topic still fails at the call site. - return definitions; -} - -function isSafeNamespace(value: unknown): value is readonly unknown[] { - if ( - !Array.isArray(value) || - value.length < 1 || - value.length > MAX_NAMESPACE_PARTS || - typeof value[0] !== "string" - ) { - return false; - } - try { - const serialized = JSON.stringify(value); - return ( - typeof serialized === "string" && - new TextEncoder().encode(serialized).byteLength <= - MAX_NAMESPACE_BYTES - ); - } catch { - return false; + for (const topic of topicVersions.keys()) { + if (!index.namespacesForTopic.has(topic)) { + throw new TypeError("Query invalidation registry is invalid."); + } } } diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index 373069a..b500222 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -10,7 +10,6 @@ import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.t import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts"; import { createTanStackCacheCoordinator, - type InstalledQueryInvalidationDefinition, } from "../adapters/query-cache/tanstack-cache-coordinator.ts"; import { createQueryClient } from "../adapters/query-cache/tanstack-query-cache.ts"; import { createServerStateScopeRuntime } from "../adapters/query-cache/server-state-scope-runtime.ts"; @@ -22,7 +21,10 @@ import type { ReleaseInfo } from "../application/ports/release-info-port.ts"; import { createRestProviderProfile } from "../contracts/rest-profiles.ts"; import type { ClockPort } from "../application/ports/clock-port.ts"; import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts"; -import { QUERY_REGISTRY } from "../features/installed-feature-contracts.ts"; +import { + INVALIDATION_REGISTRY, + INVALIDATION_TOPIC_VERSIONS, +} from "../features/installed-feature-contracts.ts"; import { INSTALLED_RUNTIME_CAPABILITIES } from "../features/installed-runtime-capabilities.ts"; import { describeRuntimeCapabilities } from "../contracts/runtime-capabilities.ts"; import { @@ -32,6 +34,10 @@ import { import type { RuntimeConfigLoadResult } from "./load-runtime-config.ts"; import { createServerStateGenerationStore } from "./server-state-generation-store.ts"; import { COMPOSED_CONTRACT_CONTRIBUTIONS } from "../features/installed-contract-contributions.ts"; +import { + indexInvalidationRegistry, + indexInvalidationTopicVersions, +} from "../contracts/query-invalidation.ts"; type HttpClientDependencies = Parameters[0]; export type RuntimeHttpContract = Pick< @@ -184,12 +190,11 @@ export async function createRuntimeAdapters( }); }, }); - // The composition root states the registry shape it consumes rather than - // inferring it from whichever features happen to be installed, so a build - // with zero installed features still type-checks. - const queryRegistry: Readonly< - Record - > = QUERY_REGISTRY; + const invalidationIndex = indexInvalidationRegistry(INVALIDATION_REGISTRY); + const invalidationTopicVersions = indexInvalidationTopicVersions( + INVALIDATION_REGISTRY, + INVALIDATION_TOPIC_VERSIONS, + ); const conditionalValidators = createConditionalValidatorStore(); const serverStateGeneration = createServerStateGenerationStore(() => { const queryClient = createQueryClient({ diagnostics }); @@ -197,10 +202,10 @@ export async function createRuntimeAdapters( createBrowserCrossContextInvalidationFromHost({ ...(context.host === undefined ? {} : { host: context.host }), cacheEpoch: `release.${context.release.releaseId}`, - topics: Object.values(queryRegistry).map((definition) => + topics: [...invalidationTopicVersions].map(([topic, topicVersion]) => Object.freeze({ - topic: definition.invalidationTopic, - topicVersion: definition.version, + topic, + topicVersion, }), ), observe(observation) { @@ -223,7 +228,8 @@ export async function createRuntimeAdapters( }); const queryInvalidation = createTanStackCacheCoordinator({ queryClient, - queryRegistry, + invalidationIndex, + topicVersions: invalidationTopicVersions, crossContext: crossContextInvalidation, diagnostics, }); diff --git a/src/contracts/query-invalidation.ts b/src/contracts/query-invalidation.ts index 407ede5..0b00a1c 100644 --- a/src/contracts/query-invalidation.ts +++ b/src/contracts/query-invalidation.ts @@ -1,4 +1,8 @@ import { isCacheInvalidationTopic } from "./cache-invalidation.ts"; +import { + queryNamespaceIdentityKey, + type QueryNamespaceIdentity, +} from "./query-keys.ts"; declare const queryInvalidationTopicBrand: unique symbol; @@ -37,20 +41,28 @@ export const INVALIDATION_REGISTRY_BOUNDS = Object.freeze({ export type InvalidationRegistryEdge = Readonly<{ topicId: string; - namespace: string; + namespace: QueryNamespaceIdentity; }>; export interface InvalidationRegistry { readonly topics: readonly string[]; - readonly namespaces: readonly string[]; + readonly namespaces: readonly QueryNamespaceIdentity[]; readonly edges: readonly InvalidationRegistryEdge[]; } export type InvalidationRegistryIndex = Readonly<{ - namespacesForTopic: ReadonlyMap; + namespacesForTopic: ReadonlyMap< + string, + readonly QueryNamespaceIdentity[] + >; topicsForNamespace: ReadonlyMap; }>; +export type InvalidationTopicVersionDefinition = Readonly<{ + topicId: string; + topicVersion: number; +}>; + function hasControlCharacter(value: string): boolean { for (const character of value) { const codePoint = character.codePointAt(0) ?? 0; @@ -96,44 +108,60 @@ export function indexInvalidationRegistry( } topics.add(topic); } - const namespaces = new Set(); + const namespaces = new Map(); for (const namespace of registry.namespaces) { - assertId(namespace, "namespace"); - if (namespaces.has(namespace)) { - throw new TypeError(`Duplicate invalidation namespace: ${namespace}`); + let namespaceKey: string; + try { + namespaceKey = queryNamespaceIdentityKey(namespace); + } catch (error) { + throw new TypeError("Invalidation registry namespace is invalid.", { + cause: error, + }); } - namespaces.add(namespace); + if (namespaces.has(namespaceKey)) { + throw new TypeError(`Duplicate invalidation namespace: ${namespaceKey}`); + } + namespaces.set(namespaceKey, namespace); } - const namespacesForTopic = new Map(); + const namespacesForTopic = new Map(); const topicsForNamespace = new Map(); const seenEdges = new Map>(); for (const edge of registry.edges) { - if (!topics.has(edge.topicId) || !namespaces.has(edge.namespace)) { + let namespaceKey: string; + try { + namespaceKey = queryNamespaceIdentityKey(edge.namespace); + } catch (error) { + throw new TypeError("Invalidation registry namespace is invalid.", { + cause: error, + }); + } + const registeredNamespace = namespaces.get(namespaceKey); + if (!topics.has(edge.topicId) || !registeredNamespace) { throw new TypeError("Invalidation edge references an unknown endpoint."); } const seenNamespaces = seenEdges.get(edge.topicId) ?? new Set(); - if (seenNamespaces.has(edge.namespace)) { + if (seenNamespaces.has(namespaceKey)) { throw new TypeError("Duplicate invalidation edge."); } - seenNamespaces.add(edge.namespace); + seenNamespaces.add(namespaceKey); seenEdges.set(edge.topicId, seenNamespaces); const fanOut = namespacesForTopic.get(edge.topicId) ?? []; - fanOut.push(edge.namespace); + fanOut.push(registeredNamespace); if (fanOut.length > bounds.maxTopicFanOut) { throw new TypeError(`Invalidation topic fan-out exceeded: ${edge.topicId}`); } namespacesForTopic.set(edge.topicId, fanOut); - const fanIn = topicsForNamespace.get(edge.namespace) ?? []; + const fanIn = topicsForNamespace.get(namespaceKey) ?? []; fanIn.push(edge.topicId); if (fanIn.length > bounds.maxNamespaceFanIn) { throw new TypeError( - `Invalidation namespace fan-in exceeded: ${edge.namespace}`, + `Invalidation namespace fan-in exceeded: ${namespaceKey}`, ); } - topicsForNamespace.set(edge.namespace, fanIn); + topicsForNamespace.set(namespaceKey, fanIn); } for (const topic of topics) { @@ -141,9 +169,9 @@ export function indexInvalidationRegistry( throw new TypeError(`Orphan invalidation topic: ${topic}`); } } - for (const namespace of namespaces) { - if (!topicsForNamespace.has(namespace)) { - throw new TypeError(`Orphan invalidation namespace: ${namespace}`); + for (const namespaceKey of namespaces.keys()) { + if (!topicsForNamespace.has(namespaceKey)) { + throw new TypeError(`Orphan invalidation namespace: ${namespaceKey}`); } } @@ -151,7 +179,7 @@ export function indexInvalidationRegistry( namespacesForTopic: new Map( [...namespacesForTopic].map(([key, value]) => [ key, - Object.freeze([...value]) as readonly string[], + Object.freeze([...value]) as readonly QueryNamespaceIdentity[], ]), ), topicsForNamespace: new Map( @@ -163,6 +191,41 @@ export function indexInvalidationRegistry( }); } +/** Projects the graph's wire-only topic versions and rejects composition drift. */ +export function indexInvalidationTopicVersions( + registry: Pick, + definitions: readonly InvalidationTopicVersionDefinition[], +): ReadonlyMap { + if ( + registry.topics.length > INVALIDATION_REGISTRY_BOUNDS.maxTopics || + definitions.length > INVALIDATION_REGISTRY_BOUNDS.maxTopics + ) { + throw new TypeError("Invalidation topic version registry exceeds its bounds."); + } + const registeredTopics = new Set(registry.topics); + if ( + registeredTopics.size !== registry.topics.length || + definitions.length !== registeredTopics.size + ) { + throw new TypeError("Invalidation topic version registry is inconsistent."); + } + + const versions = new Map(); + for (const definition of definitions) { + if ( + !isCacheInvalidationTopic(definition.topicId) || + !registeredTopics.has(definition.topicId) || + !Number.isSafeInteger(definition.topicVersion) || + definition.topicVersion < 1 || + versions.has(definition.topicId) + ) { + throw new TypeError("Invalidation topic version registry is invalid."); + } + versions.set(definition.topicId, definition.topicVersion); + } + return versions; +} + export type QueryMutationLease = Readonly<{ /** * Releases one local mutation fence. Remote hints coalesced while the fence diff --git a/src/contracts/query-keys.ts b/src/contracts/query-keys.ts index 0d67187..2a0f23f 100644 --- a/src/contracts/query-keys.ts +++ b/src/contracts/query-keys.ts @@ -1,6 +1,3 @@ -export const QUERY_REGISTRY: Readonly> = - Object.freeze({}); - export const QUERY_KEY_SCHEMA_VERSION = 2 as const; export const QUERY_NAMESPACE_ID_MAX_BYTES = 80 as const; diff --git a/src/features/installed-feature-contracts.ts b/src/features/installed-feature-contracts.ts index 486e4f3..e5ce974 100644 --- a/src/features/installed-feature-contracts.ts +++ b/src/features/installed-feature-contracts.ts @@ -32,9 +32,26 @@ export const REST_PROFILE_BINDINGS_VALID = validateRestProfileBindings( API_OPERATIONS, Object.freeze({ PRIMARY_API: Object.freeze(["omit"] as const) }), ); -export const QUERY_REGISTRY = Object.freeze({ - ...REFERENCE_FEATURE_CONTRACT.queryRegistry, +export const INVALIDATION_REGISTRY = Object.freeze({ + topics: Object.freeze( + INSTALLED_FEATURE_CONTRACTS.flatMap( + (contract) => contract.invalidation.topics, + ), + ), + namespaces: Object.freeze( + INSTALLED_FEATURE_CONTRACTS.flatMap( + (contract) => contract.invalidation.namespaces, + ), + ), + edges: Object.freeze( + INSTALLED_FEATURE_CONTRACTS.flatMap( + (contract) => contract.invalidation.edges, + ), + ), }); +export const INVALIDATION_TOPIC_VERSIONS = Object.freeze( + INSTALLED_FEATURE_CONTRACTS.flatMap((contract) => contract.topicVersions), +); export const SCHEMA_REGISTRY = composeSchemaRegistry([ PLATFORM_SCHEMA_REGISTRY, ...INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.schemas), diff --git a/src/features/reference-feature/contracts/reference-feature-contract.ts b/src/features/reference-feature/contracts/reference-feature-contract.ts index e91a6bc..70329f2 100644 --- a/src/features/reference-feature/contracts/reference-feature-contract.ts +++ b/src/features/reference-feature/contracts/reference-feature-contract.ts @@ -1,23 +1,17 @@ -import { canonicalize } from "../../../contracts/query-keys.ts"; +import { defineQueryNamespaceIdentity } from "../../../contracts/query-keys.ts"; import { defineQueryInvalidationTopic } from "../../../contracts/query-invalidation.ts"; import { defineRestOperation } from "../../../contracts/api-operations.ts"; import { REFERENCE_RUNTIME_SCHEMA_CODECS } from "./reference-schemas.ts"; import { REFERENCE_BOUNDARY_MAPPERS } from "./reference-mapper.ts"; export const REFERENCE_FEATURE_ID = "reference-feature"; -const REFERENCE_NAMESPACE = Object.freeze(["reference-resource", 1] as const); +const REFERENCE_RESOURCE_QUERY_NAMESPACE = defineQueryNamespaceIdentity( + "reference-resource", + 1, +); export const REFERENCE_RESOURCE_INVALIDATION_TOPIC = defineQueryInvalidationTopic("qinv.01k10f7m3w9p6r2c8v5n4x"); -export const referenceQueryKeys = Object.freeze({ - all: () => REFERENCE_NAMESPACE, - list: (filters: Readonly = {}) => - Object.freeze([...REFERENCE_NAMESPACE, "list", canonicalize(filters)]), - - detail: (resourceId: string) => - Object.freeze([...REFERENCE_NAMESPACE, "detail", String(resourceId)]), -}); - export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ featureId: REFERENCE_FEATURE_ID, runtimeSchemas: REFERENCE_RUNTIME_SCHEMA_CODECS, @@ -244,16 +238,20 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ maxEncodedSearchBytes: 0, }), }), - queryRegistry: Object.freeze({ - REFERENCE_RESOURCE: Object.freeze({ - namespace: REFERENCE_NAMESPACE, - serialization: "canonical-object-order", - identity: "no-pii-token-or-raw-url", - invalidation: "reference resource namespace after successful mutation", - invalidationTopic: REFERENCE_RESOURCE_INVALIDATION_TOPIC, - crossContext: "invalidate-only", - version: 1, - persistence: "disabled", - }), + invalidation: Object.freeze({ + topics: Object.freeze([REFERENCE_RESOURCE_INVALIDATION_TOPIC]), + namespaces: Object.freeze([REFERENCE_RESOURCE_QUERY_NAMESPACE]), + edges: Object.freeze([ + Object.freeze({ + topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC, + namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE, + }), + ]), }), + topicVersions: Object.freeze([ + Object.freeze({ + topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC, + topicVersion: 1, + }), + ]), } as const); diff --git a/tests/component/application-query.test.tsx b/tests/component/application-query.test.tsx index 4ea9e3d..7c6331d 100644 --- a/tests/component/application-query.test.tsx +++ b/tests/component/application-query.test.tsx @@ -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), ), ); }); diff --git a/tests/features/reference-feature/reference-contract.test.ts b/tests/features/reference-feature/reference-contract.test.ts index b2f38f0..2ba9dcc 100644 --- a/tests/features/reference-feature/reference-contract.test.ts +++ b/tests/features/reference-feature/reference-contract.test.ts @@ -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, + }, ]); }); diff --git a/tests/unit/navigation-policy.test.ts b/tests/unit/navigation-policy.test.ts index 1973e2c..35f492d 100644 --- a/tests/unit/navigation-policy.test.ts +++ b/tests/unit/navigation-policy.test.ts @@ -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>, + ).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", () => { diff --git a/tests/unit/query-invalidation-registry.test.ts b/tests/unit/query-invalidation-registry.test.ts index 48a4600..11c265e 100644 --- a/tests/unit/query-invalidation-registry.test.ts +++ b/tests/unit/query-invalidation-registry.test.ts @@ -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 }) => { diff --git a/tests/unit/registry-governance.test.ts b/tests/unit/registry-governance.test.ts index 51f1252..5f2d1e0 100644 --- a/tests/unit/registry-governance.test.ts +++ b/tests/unit/registry-governance.test.ts @@ -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( diff --git a/tests/unit/runtime-adapters.test.ts b/tests/unit/runtime-adapters.test.ts index 783e998..1979b71 100644 --- a/tests/unit/runtime-adapters.test.ts +++ b/tests/unit/runtime-adapters.test.ts @@ -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[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(); diff --git a/tests/unit/tanstack-cache-coordinator.test.ts b/tests/unit/tanstack-cache-coordinator.test.ts index a590873..4a9603a 100644 --- a/tests/unit/tanstack-cache-coordinator.test.ts +++ b/tests/unit/tanstack-cache-coordinator.test.ts @@ -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(() =>