chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user