242 lines
6.2 KiB
TypeScript
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 });
|
|
}
|