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
@@ -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<string, InstalledQueryInvalidationDefinition>
>;
invalidationIndex: InvalidationRegistryIndex;
topicVersions: ReadonlyMap<string, number>;
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<QueryInvalidationTopic, number>();
const pendingRemote = new Set<QueryInvalidationTopic>();
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<void> {
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<string, QueryNamespaceIdentity>();
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<void> {
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<string, InstalledQueryInvalidationDefinition>
>,
): ReadonlyMap<string, RuntimeDefinition> {
const definitions = new Map<string, RuntimeDefinition>();
for (const candidate of Object.values(registry)) {
function validateConfiguration(
index: InvalidationRegistryIndex,
topicVersions: ReadonlyMap<string, number>,
): 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<string>();
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.");
}
}
}
+18 -12
View File
@@ -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<typeof createHttpClient>[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<string, InstalledQueryInvalidationDefinition>
> = 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,
});
+83 -20
View File
@@ -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<string, readonly string[]>;
namespacesForTopic: ReadonlyMap<
string,
readonly QueryNamespaceIdentity[]
>;
topicsForNamespace: ReadonlyMap<string, readonly string[]>;
}>;
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<string>();
const namespaces = new Map<string, QueryNamespaceIdentity>();
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<string, string[]>();
const namespacesForTopic = new Map<string, QueryNamespaceIdentity[]>();
const topicsForNamespace = new Map<string, string[]>();
const seenEdges = new Map<string, Set<string>>();
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<string>();
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<InvalidationRegistry, "topics">,
definitions: readonly InvalidationTopicVersionDefinition[],
): ReadonlyMap<string, number> {
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<string, number>();
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
-3
View File
@@ -1,6 +1,3 @@
export const QUERY_REGISTRY: Readonly<Record<string, readonly unknown[]>> =
Object.freeze({});
export const QUERY_KEY_SCHEMA_VERSION = 2 as const;
export const QUERY_NAMESPACE_ID_MAX_BYTES = 80 as const;
+19 -2
View File
@@ -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),
@@ -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> = {}) =>
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);