fix: index many-to-many query invalidation
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
"failures": { "type": "array", "maxItems": 0 },
|
||||
"registries": {
|
||||
"type": "array",
|
||||
"minItems": 10,
|
||||
"maxItems": 10,
|
||||
"minItems": 9,
|
||||
"maxItems": 9,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, unknown>>): unknown;
|
||||
}>;
|
||||
export function createInstalledFeatureInputs(_context: FeatureContext) {
|
||||
const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) {
|
||||
void _context;
|
||||
return Object.freeze({});
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,38 +6,81 @@ import type {
|
||||
CrossContextInvalidationDelivery,
|
||||
} from "../../src/adapters/cross-context-invalidation/index.ts";
|
||||
import { createTanStackCacheCoordinator } from "../../src/adapters/query-cache/tanstack-cache-coordinator.ts";
|
||||
import { defineQueryInvalidationTopic } from "../../src/contracts/query-invalidation.ts";
|
||||
import {
|
||||
defineQueryInvalidationTopic,
|
||||
indexInvalidationRegistry,
|
||||
} from "../../src/contracts/query-invalidation.ts";
|
||||
import {
|
||||
createRuntimeIdentityRegistry,
|
||||
defineQueryNamespaceIdentity,
|
||||
type QueryNamespaceIdentity,
|
||||
} from "../../src/contracts/query-keys.ts";
|
||||
import { bindQuery } from "../../src/contracts/server-state.ts";
|
||||
import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts";
|
||||
|
||||
const TOPIC_A = defineQueryInvalidationTopic("qinv.topic-a");
|
||||
const TOPIC_B = defineQueryInvalidationTopic("qinv.topic-b");
|
||||
const NAMESPACE_A = defineQueryNamespaceIdentity("resource-a", 1);
|
||||
const NAMESPACE_B = defineQueryNamespaceIdentity("resource-b", 1);
|
||||
const NAMESPACE_C = defineQueryNamespaceIdentity("resource-c", 1);
|
||||
|
||||
function queryRegistry() {
|
||||
return Object.freeze({
|
||||
A: Object.freeze({
|
||||
namespace: Object.freeze(["resource-a", 1] as const),
|
||||
invalidationTopic: TOPIC_A,
|
||||
crossContext: "invalidate-only" as const,
|
||||
version: 1,
|
||||
persistence: "disabled" as const,
|
||||
}),
|
||||
B: Object.freeze({
|
||||
namespace: Object.freeze(["resource-b", 1] as const),
|
||||
invalidationTopic: TOPIC_B,
|
||||
crossContext: "invalidate-only" as const,
|
||||
version: 1,
|
||||
persistence: "disabled" as const,
|
||||
}),
|
||||
function invalidationIndex() {
|
||||
return indexInvalidationRegistry({
|
||||
topics: [TOPIC_A, TOPIC_B],
|
||||
namespaces: [NAMESPACE_A, NAMESPACE_B, NAMESPACE_C],
|
||||
edges: [
|
||||
{ topicId: TOPIC_A, namespace: NAMESPACE_A },
|
||||
{ topicId: TOPIC_A, namespace: NAMESPACE_B },
|
||||
{ topicId: TOPIC_B, namespace: NAMESPACE_B },
|
||||
{ topicId: TOPIC_B, namespace: NAMESPACE_C },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function topicVersions() {
|
||||
return new Map([
|
||||
[TOPIC_A, 1],
|
||||
[TOPIC_B, 1],
|
||||
]);
|
||||
}
|
||||
|
||||
function realBoundQueryKey(namespace: QueryNamespaceIdentity) {
|
||||
const scope: CacheScopeSnapshot = {
|
||||
generation: 1,
|
||||
fingerprint: "scope-fingerprint-0001",
|
||||
identities: createRuntimeIdentityRegistry({
|
||||
tokenFactory: () => `identity-token-${namespace.namespaceId}`,
|
||||
}),
|
||||
signal: new AbortController().signal,
|
||||
isCurrent: () => true,
|
||||
};
|
||||
return bindQuery(
|
||||
{
|
||||
definitionId: `${namespace.namespaceId}-query-v1`,
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: namespace.namespaceId,
|
||||
namespaceVersion: namespace.namespaceVersion,
|
||||
operationId: `GET_${namespace.namespaceId.toUpperCase()}`,
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: () => ({ itemCount: 1, estimatedBytes: 8 }),
|
||||
execute: async () => ({ ok: true as const, value: namespace.namespaceId }),
|
||||
},
|
||||
{ selected: namespace.namespaceId },
|
||||
scope,
|
||||
).queryKey;
|
||||
}
|
||||
|
||||
function crossContextHarness() {
|
||||
let listener:
|
||||
| ((delivery: CrossContextInvalidationDelivery) => void)
|
||||
| undefined;
|
||||
const publish = vi.fn(() => ({
|
||||
ok: true as const,
|
||||
transport: "BROADCAST" as const,
|
||||
}));
|
||||
const publish = vi.fn(
|
||||
(_event: { topic: string; topicVersion: number }) => ({
|
||||
ok: true as const,
|
||||
transport: "BROADCAST" as const,
|
||||
}),
|
||||
);
|
||||
const close = vi.fn();
|
||||
const transport: BrowserCrossContextInvalidation = {
|
||||
getStatus: () => "ACTIVE_BROADCAST",
|
||||
@@ -85,61 +128,88 @@ function createClient(): QueryClient {
|
||||
}
|
||||
|
||||
describe("TanStack cross-context cache coordinator", () => {
|
||||
it("maps a local opaque topic to one namespace and publishes no query key", async () => {
|
||||
it("invalidates every real V2 key connected to one local topic and publishes only topic identity", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "list"], ["a"]);
|
||||
client.setQueryData(["resource-b", 1, "list"], ["b"]);
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
const keyA = realBoundQueryKey(NAMESPACE_A);
|
||||
const keyB = realBoundQueryKey(NAMESPACE_B);
|
||||
const unrelatedKey = realBoundQueryKey(NAMESPACE_C);
|
||||
client.setQueryData(keyA, ["a"]);
|
||||
client.setQueryData(keyB, ["b"]);
|
||||
client.setQueryData(unrelatedKey, ["c"]);
|
||||
const dependencies = {
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
};
|
||||
const coordinator = createTanStackCacheCoordinator(dependencies);
|
||||
|
||||
await coordinator.invalidate([TOPIC_A]);
|
||||
|
||||
expect(
|
||||
client.getQueryState(["resource-a", 1, "list"])?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
client.getQueryState(["resource-b", 1, "list"])?.isInvalidated,
|
||||
).toBe(false);
|
||||
expect(harness.publish).toHaveBeenCalledWith({
|
||||
expect(client.getQueryState(keyA)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(keyB)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(unrelatedKey)?.isInvalidated).toBe(false);
|
||||
expect(harness.publish).toHaveBeenCalledOnce();
|
||||
expect(harness.publish.mock.calls[0]?.[0]).toEqual({
|
||||
topic: TOPIC_A,
|
||||
topicVersion: 1,
|
||||
});
|
||||
expect(JSON.stringify(harness.publish.mock.calls)).not.toContain(
|
||||
"resource-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies a remote hint without publishing an echo", async () => {
|
||||
it("invalidates every real V2 key connected to one remote topic without echoing it", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "detail", "opaque"], {
|
||||
value: true,
|
||||
});
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
const keyA = realBoundQueryKey(NAMESPACE_A);
|
||||
const keyB = realBoundQueryKey(NAMESPACE_B);
|
||||
const unrelatedKey = realBoundQueryKey(NAMESPACE_C);
|
||||
client.setQueryData(keyA, ["a"]);
|
||||
client.setQueryData(keyB, ["b"]);
|
||||
client.setQueryData(unrelatedKey, ["c"]);
|
||||
const dependencies = {
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
};
|
||||
const coordinator = createTanStackCacheCoordinator(dependencies);
|
||||
|
||||
harness.deliver(TOPIC_A);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
client.getQueryState([
|
||||
"resource-a",
|
||||
1,
|
||||
"detail",
|
||||
"opaque",
|
||||
])?.isInvalidated,
|
||||
).toBe(true),
|
||||
);
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
|
||||
coordinator.dispose();
|
||||
expect(harness.close).toHaveBeenCalledOnce();
|
||||
await vi.waitFor(() => {
|
||||
expect(client.getQueryState(keyA)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(keyB)?.isInvalidated).toBe(true);
|
||||
});
|
||||
expect(client.getQueryState(unrelatedKey)?.isInvalidated).toBe(false);
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidates each real V2 namespace once when a sequence gap spans overlapping topics", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
const keyA = realBoundQueryKey(NAMESPACE_A);
|
||||
const keyB = realBoundQueryKey(NAMESPACE_B);
|
||||
const keyC = realBoundQueryKey(NAMESPACE_C);
|
||||
client.setQueryData(keyA, ["a"]);
|
||||
client.setQueryData(keyB, ["b"]);
|
||||
client.setQueryData(keyC, ["c"]);
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
const dependencies = {
|
||||
queryClient: client,
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
};
|
||||
createTanStackCacheCoordinator(dependencies);
|
||||
|
||||
harness.deliver(TOPIC_A, "GAP");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(client.getQueryState(keyA)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(keyB)?.isInvalidated).toBe(true);
|
||||
expect(client.getQueryState(keyC)?.isInvalidated).toBe(true);
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("coalesces remote hints while a local mutation lease is held", async () => {
|
||||
@@ -149,7 +219,8 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
const lease = coordinator.beginMutation([TOPIC_A]);
|
||||
@@ -160,32 +231,10 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
|
||||
await lease.release();
|
||||
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(invalidate).toHaveBeenCalledTimes(2);
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconciles every registered namespace when a source sequence has a gap", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "list"], ["a"]);
|
||||
client.setQueryData(["resource-b", 1, "list"], ["b"]);
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
harness.deliver(TOPIC_A, "GAP");
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
client.getQueryState(["resource-a", 1, "list"])?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
client.getQueryState(["resource-b", 1, "list"])?.isInvalidated,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("fences remote delivery until a local reset has cancelled and cleared the cache", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
@@ -202,7 +251,8 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
@@ -215,7 +265,7 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
finishCancellation?.();
|
||||
await reset;
|
||||
expect(client.getQueryData(["resource-a", 1, "list"])).toBeUndefined();
|
||||
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledTimes(2));
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -228,7 +278,8 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
const clear = vi.spyOn(client, "clear");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
});
|
||||
|
||||
await expect(coordinator.resetLocal()).rejects.toThrow(
|
||||
@@ -244,7 +295,12 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
const harness = crossContextHarness();
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: createClient(),
|
||||
queryRegistry: Object.freeze({}),
|
||||
invalidationIndex: indexInvalidationRegistry({
|
||||
topics: [],
|
||||
namespaces: [],
|
||||
edges: [],
|
||||
}),
|
||||
topicVersions: new Map(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
@@ -257,7 +313,8 @@ describe("TanStack cross-context cache coordinator", () => {
|
||||
it("rejects an unregistered topic before opening a mutation lease", () => {
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: createClient(),
|
||||
queryRegistry: queryRegistry(),
|
||||
invalidationIndex: invalidationIndex(),
|
||||
topicVersions: topicVersions(),
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
|
||||
Reference in New Issue
Block a user