import { isCacheInvalidationTopic } from "./cache-invalidation.ts"; import { defineQueryNamespaceIdentity, queryNamespaceIdentityKey, type QueryNamespaceIdentity, } from "./query-keys.ts"; declare const queryInvalidationTopicBrand: unique symbol; /** * Opaque registry-issued invalidation identity. The brand prevents feature * code from accidentally passing a concrete query-key string to the mutation * bridge. */ export type QueryInvalidationTopic = string & Readonly<{ [queryInvalidationTopicBrand]: true }>; export function defineQueryInvalidationTopic( value: string, ): QueryInvalidationTopic { if (!isCacheInvalidationTopic(value)) { throw new TypeError("Query invalidation topic is invalid."); } return value as QueryInvalidationTopic; } /** * ยง12.2. Many-to-many topic/namespace registry. * * Topics stay opaque on the wire; the registry is what turns one received topic * into the local namespaces that must revalidate. Bounds are checked at startup * so a fan-out explosion cannot be introduced at runtime. */ export const INVALIDATION_REGISTRY_BOUNDS = Object.freeze({ maxTopics: 256, maxNamespaces: 256, maxEdges: 1_024, maxTopicFanOut: 64, maxNamespaceFanIn: 64, maxIdBytes: 80, }); export type InvalidationRegistryEdge = Readonly<{ topicId: string; namespace: QueryNamespaceIdentity; }>; export interface InvalidationRegistry { readonly topics: readonly string[]; readonly namespaces: readonly QueryNamespaceIdentity[]; readonly edges: readonly InvalidationRegistryEdge[]; } export type InvalidationRegistryIndex = Readonly<{ 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; if (codePoint <= 0x1f || codePoint === 0x7f) return true; } return false; } /** * Rejects duplicates, orphan topics and orphan namespaces at startup. An edge * that points at an unregistered endpoint is a composition defect, not a * runtime condition to be tolerated. */ export function indexInvalidationRegistry( registry: InvalidationRegistry, ): InvalidationRegistryIndex { const bounds = INVALIDATION_REGISTRY_BOUNDS; const encoder = new TextEncoder(); const assertId = (value: string, label: string) => { if ( typeof value !== "string" || value.length === 0 || hasControlCharacter(value) || encoder.encode(value).byteLength > bounds.maxIdBytes ) { throw new TypeError(`Invalidation registry ${label} is invalid.`); } }; if ( registry.topics.length > bounds.maxTopics || registry.namespaces.length > bounds.maxNamespaces || registry.edges.length > bounds.maxEdges ) { throw new TypeError("Invalidation registry exceeds its bounds."); } const topics = new Set(); for (const topic of registry.topics) { assertId(topic, "topic"); if (topics.has(topic)) { throw new TypeError(`Duplicate invalidation topic: ${topic}`); } topics.add(topic); } const namespaces = new Map(); for (const namespace of registry.namespaces) { let namespaceKey: string; let namespaceSnapshot: QueryNamespaceIdentity; try { namespaceSnapshot = defineQueryNamespaceIdentity( namespace.namespaceId, namespace.namespaceVersion, ); namespaceKey = queryNamespaceIdentityKey(namespaceSnapshot); } catch (error) { throw new TypeError("Invalidation registry namespace is invalid.", { cause: error, }); } if (namespaces.has(namespaceKey)) { throw new TypeError(`Duplicate invalidation namespace: ${namespaceKey}`); } namespaces.set(namespaceKey, namespaceSnapshot); } const namespacesForTopic = new Map(); const topicsForNamespace = new Map(); const seenEdges = new Map>(); for (const edge of registry.edges) { 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(namespaceKey)) { throw new TypeError("Duplicate invalidation edge."); } seenNamespaces.add(namespaceKey); seenEdges.set(edge.topicId, seenNamespaces); const fanOut = namespacesForTopic.get(edge.topicId) ?? []; 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(namespaceKey) ?? []; fanIn.push(edge.topicId); if (fanIn.length > bounds.maxNamespaceFanIn) { throw new TypeError( `Invalidation namespace fan-in exceeded: ${namespaceKey}`, ); } topicsForNamespace.set(namespaceKey, fanIn); } for (const topic of topics) { if (!namespacesForTopic.has(topic)) { throw new TypeError(`Orphan invalidation topic: ${topic}`); } } for (const namespaceKey of namespaces.keys()) { if (!topicsForNamespace.has(namespaceKey)) { throw new TypeError(`Orphan invalidation namespace: ${namespaceKey}`); } } return Object.freeze({ namespacesForTopic: new Map( [...namespacesForTopic].map(([key, value]) => [ key, Object.freeze([...value]) as readonly QueryNamespaceIdentity[], ]), ), topicsForNamespace: new Map( [...topicsForNamespace].map(([key, value]) => [ key, Object.freeze([...value]) as readonly string[], ]), ), }); } /** 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 * was held are applied once after the final lease for each topic is released. */ release(): Promise; }>; /** * Presentation-side facade for server-state invalidation. * * The caller knows only registry-issued topics. Query keys, BroadcastChannel * envelopes and browser transports stay inside the query infrastructure. */ export interface QueryInvalidationCoordinator { invalidate(topics: readonly QueryInvalidationTopic[]): Promise; beginMutation(topics: readonly QueryInvalidationTopic[]): QueryMutationLease; /** * Local verified lifecycle only. A remote invalidation hint is never allowed * to clear the complete cache. */ resetLocal(): Promise; dispose(): void; }