72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
import { SmsReceiptNotFoundError } from "../error-codes/error-codes.js";
|
|
import type {
|
|
DeliveryTrackerOptions,
|
|
SmsDeliveryReport,
|
|
SmsProvider,
|
|
} from "../types/sms.types.js";
|
|
|
|
/**
|
|
* Bounded LRU cache of `SmsDeliveryReport` keyed by `messageId`. Receipts
|
|
* may arrive via webhook ingest (`ingest`) or be lazily fetched from a
|
|
* provider (`lookupOrFetch`). The cache is bounded so a long-running
|
|
* service does not retain every receipt indefinitely.
|
|
*/
|
|
export class DeliveryTracker {
|
|
private readonly maxEntries: number;
|
|
private readonly cache = new Map<string, SmsDeliveryReport>();
|
|
|
|
public constructor(options: DeliveryTrackerOptions = {}) {
|
|
this.maxEntries = options.maxEntries ?? 1024;
|
|
if (this.maxEntries < 1) {
|
|
throw new Error("DeliveryTracker.maxEntries must be ≥ 1");
|
|
}
|
|
}
|
|
|
|
/** Record a delivery report; evicts the oldest entry past `maxEntries`. */
|
|
public ingest(report: SmsDeliveryReport): void {
|
|
if (this.cache.has(report.messageId)) {
|
|
this.cache.delete(report.messageId);
|
|
}
|
|
this.cache.set(report.messageId, report);
|
|
if (this.cache.size > this.maxEntries) {
|
|
const oldest = this.cache.keys().next().value as string;
|
|
this.cache.delete(oldest);
|
|
}
|
|
}
|
|
|
|
/** Read the cached status; returns `undefined` on miss. */
|
|
public getStatus(messageId: string): SmsDeliveryReport | undefined {
|
|
const entry = this.cache.get(messageId);
|
|
if (entry === undefined) return undefined;
|
|
// Re-insert to refresh LRU order.
|
|
this.cache.delete(messageId);
|
|
this.cache.set(messageId, entry);
|
|
return entry;
|
|
}
|
|
|
|
/** Cache miss → consult provider; throws `SmsReceiptNotFoundError` if no provider. */
|
|
public async lookupOrFetch(
|
|
messageId: string,
|
|
provider?: SmsProvider
|
|
): Promise<SmsDeliveryReport> {
|
|
const cached = this.getStatus(messageId);
|
|
if (cached !== undefined) return cached;
|
|
if (provider === undefined) {
|
|
throw new SmsReceiptNotFoundError({ context: { messageId } });
|
|
}
|
|
const report = await provider.getStatus(messageId);
|
|
this.ingest(report);
|
|
return report;
|
|
}
|
|
|
|
/** Number of cached entries. */
|
|
public size(): number {
|
|
return this.cache.size;
|
|
}
|
|
|
|
/** Drop every cached entry. */
|
|
public clear(): void {
|
|
this.cache.clear();
|
|
}
|
|
}
|