41 lines
1.1 KiB
TypeScript
41 lines
1.1 KiB
TypeScript
/**
|
|
* Bounded replay cache for Discord's globally unique native message IDs.
|
|
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
|
|
*/
|
|
export class DiscordReplayProtector {
|
|
private readonly claimedAt = new Map<string, number>();
|
|
|
|
constructor(
|
|
private readonly ttlMs = 15 * 60 * 1000,
|
|
private readonly maxEntries = 10_000,
|
|
) {}
|
|
|
|
get size(): number {
|
|
return this.claimedAt.size;
|
|
}
|
|
|
|
/** Claims an ID exactly once within its bounded retention window. */
|
|
claim(messageId: string, now = Date.now()): boolean {
|
|
this.prune(now);
|
|
if (this.claimedAt.has(messageId)) return false;
|
|
|
|
this.claimedAt.set(messageId, now);
|
|
this.evictOverflow();
|
|
return true;
|
|
}
|
|
|
|
private prune(now: number): void {
|
|
for (const [messageId, claimedAt] of this.claimedAt) {
|
|
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
|
|
}
|
|
}
|
|
|
|
private evictOverflow(): void {
|
|
while (this.claimedAt.size > this.maxEntries) {
|
|
const oldestMessageId = this.claimedAt.keys().next().value;
|
|
if (oldestMessageId === undefined) return;
|
|
this.claimedAt.delete(oldestMessageId);
|
|
}
|
|
}
|
|
}
|