Files
tech-log-frontend/src/contracts/cache-invalidation.ts
T

242 lines
6.2 KiB
TypeScript

/**
* Cross-context cache invalidation is a best-effort hint protocol. The wire
* event intentionally carries neither cached data nor a concrete query key.
* Receivers resolve the allowlisted topic through their local policy.
*/
export const CACHE_INVALIDATION_PROTOCOL_VERSION = 1 as const;
export const CACHE_INVALIDATION_WIRE_LIMITS = Object.freeze({
maxWireBytes: 2_048,
maxOpaqueIdentifierLength: 128,
maxTopicLength: 64,
maxEventTtlMs: 5 * 60 * 1_000,
maxFutureClockSkewMs: 30_000,
});
export type CacheInvalidationTopicDefinition = Readonly<{
topic: string;
topicVersion: number;
}>;
export type CacheInvalidationWireEvent = Readonly<{
protocolVersion: typeof CACHE_INVALIDATION_PROTOCOL_VERSION;
eventId: string;
sourceId: string;
sourceEpoch: string;
sequence: number;
cacheEpoch: string;
topic: string;
topicVersion: number;
emittedAt: number;
expiresAt: number;
}>;
export type CacheInvalidationParseFailureReason =
| "CACHE_EPOCH_MISMATCH"
| "EXPIRED"
| "INVALID_ENVELOPE"
| "MALFORMED_JSON"
| "OVERSIZED"
| "PROTOCOL_MISMATCH"
| "TOPIC_REJECTED";
export type CacheInvalidationParseResult =
| Readonly<{ ok: true; value: CacheInvalidationWireEvent }>
| Readonly<{
ok: false;
reason: CacheInvalidationParseFailureReason;
}>;
export type CacheInvalidationParsePolicy = Readonly<{
cacheEpoch: string;
topicVersions: Readonly<Record<string, number>>;
nowEpochMilliseconds: number;
}>;
const WIRE_KEYS = Object.freeze([
"cacheEpoch",
"emittedAt",
"eventId",
"expiresAt",
"protocolVersion",
"sequence",
"sourceEpoch",
"sourceId",
"topic",
"topicVersion",
] as const);
const OPAQUE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u;
const TOPIC = /^[a-z][a-z0-9.-]*$/u;
export function isCacheInvalidationOpaqueIdentifier(
value: unknown,
): value is string {
return (
typeof value === "string" &&
value.length >= 1 &&
value.length <=
CACHE_INVALIDATION_WIRE_LIMITS.maxOpaqueIdentifierLength &&
OPAQUE_IDENTIFIER.test(value)
);
}
export function isCacheInvalidationTopic(
value: unknown,
): value is string {
return (
typeof value === "string" &&
value.length >= 1 &&
value.length <= CACHE_INVALIDATION_WIRE_LIMITS.maxTopicLength &&
TOPIC.test(value)
);
}
export function cacheInvalidationWireByteLength(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
export function decodeCacheInvalidationWireEvent(
raw: string,
policy: CacheInvalidationParsePolicy,
): CacheInvalidationParseResult {
if (
typeof raw !== "string" ||
raw.length > CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes ||
cacheInvalidationWireByteLength(raw) >
CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes
) {
return failure("OVERSIZED");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return failure("MALFORMED_JSON");
}
return parseCacheInvalidationWireEvent(parsed, policy);
}
export function parseCacheInvalidationWireEvent(
input: unknown,
policy: CacheInvalidationParsePolicy,
): CacheInvalidationParseResult {
try {
return parseCacheInvalidationWireEventUnsafe(input, policy);
} catch {
return failure("INVALID_ENVELOPE");
}
}
function parseCacheInvalidationWireEventUnsafe(
input: unknown,
policy: CacheInvalidationParsePolicy,
): CacheInvalidationParseResult {
if (!isExactWireRecord(input)) {
return failure("INVALID_ENVELOPE");
}
let serialized: string;
try {
serialized = JSON.stringify(input);
} catch {
return failure("INVALID_ENVELOPE");
}
if (
cacheInvalidationWireByteLength(serialized) >
CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes
) {
return failure("OVERSIZED");
}
if (input.protocolVersion !== CACHE_INVALIDATION_PROTOCOL_VERSION) {
return failure("PROTOCOL_MISMATCH");
}
if (
!isCacheInvalidationOpaqueIdentifier(input.eventId) ||
!isCacheInvalidationOpaqueIdentifier(input.sourceId) ||
!isCacheInvalidationOpaqueIdentifier(input.sourceEpoch) ||
!isCacheInvalidationOpaqueIdentifier(input.cacheEpoch) ||
!isCacheInvalidationTopic(input.topic) ||
!isPositiveSafeInteger(input.sequence) ||
!isPositiveSafeInteger(input.topicVersion) ||
!isEpochMilliseconds(input.emittedAt) ||
!isEpochMilliseconds(input.expiresAt) ||
input.expiresAt <= input.emittedAt ||
input.expiresAt - input.emittedAt >
CACHE_INVALIDATION_WIRE_LIMITS.maxEventTtlMs ||
!isEpochMilliseconds(policy.nowEpochMilliseconds)
) {
return failure("INVALID_ENVELOPE");
}
if (input.cacheEpoch !== policy.cacheEpoch) {
return failure("CACHE_EPOCH_MISMATCH");
}
if (
!Object.hasOwn(policy.topicVersions, input.topic) ||
policy.topicVersions[input.topic] !== input.topicVersion
) {
return failure("TOPIC_REJECTED");
}
if (
input.expiresAt <= policy.nowEpochMilliseconds ||
input.emittedAt >
policy.nowEpochMilliseconds +
CACHE_INVALIDATION_WIRE_LIMITS.maxFutureClockSkewMs
) {
return failure("EXPIRED");
}
return {
ok: true,
value: Object.freeze({
protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION,
eventId: input.eventId,
sourceId: input.sourceId,
sourceEpoch: input.sourceEpoch,
sequence: input.sequence,
cacheEpoch: input.cacheEpoch,
topic: input.topic,
topicVersion: input.topicVersion,
emittedAt: input.emittedAt,
expiresAt: input.expiresAt,
}),
};
}
function isExactWireRecord(
value: unknown,
): value is Readonly<Record<(typeof WIRE_KEYS)[number], unknown>> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const keys = Object.keys(value).sort();
return (
keys.length === WIRE_KEYS.length &&
keys.every((key, index) => key === WIRE_KEYS[index])
);
}
function isPositiveSafeInteger(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isSafeInteger(value) &&
value >= 1
);
}
function isEpochMilliseconds(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isSafeInteger(value) &&
value >= 0
);
}
function failure(
reason: CacheInvalidationParseFailureReason,
): Extract<CacheInvalidationParseResult, { ok: false }> {
return Object.freeze({ ok: false, reason });
}