259 lines
8.2 KiB
TypeScript
259 lines
8.2 KiB
TypeScript
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<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;
|
|
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<string>();
|
|
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<string, QueryNamespaceIdentity>();
|
|
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<string, QueryNamespaceIdentity[]>();
|
|
const topicsForNamespace = new Map<string, string[]>();
|
|
const seenEdges = new Map<string, Set<string>>();
|
|
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<string>();
|
|
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<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
|
|
* was held are applied once after the final lease for each topic is released.
|
|
*/
|
|
release(): Promise<void>;
|
|
}>;
|
|
|
|
/**
|
|
* 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<void>;
|
|
beginMutation(topics: readonly QueryInvalidationTopic[]): QueryMutationLease;
|
|
/**
|
|
* Local verified lifecycle only. A remote invalidation hint is never allowed
|
|
* to clear the complete cache.
|
|
*/
|
|
resetLocal(): Promise<void>;
|
|
dispose(): void;
|
|
}
|