UNPKG

@electric-sql/client

Version:

Postgres everywhere - your data, in sync, wherever you need it.

163 lines (144 loc) 4.71 kB
interface UpToDateEntry { timestamp: number cursor: string } /** * Tracks up-to-date messages to detect when we're replaying cached responses. * * When a shape receives an up-to-date, we record the timestamp and cursor in localStorage. * On page refresh, if we find a recent timestamp (< 60s), we know we'll be replaying * cached responses. We suppress their up-to-date notifications until we see a NEW cursor * (different from the last recorded one), which indicates fresh data from the server. * * localStorage writes are throttled to once per 60 seconds to avoid performance issues * with frequent updates. In-memory data is always kept current. */ export class UpToDateTracker { private data: Record<string, UpToDateEntry> = {} private readonly storageKey = `electric_up_to_date_tracker` private readonly cacheTTL = 60_000 // 60s to match typical CDN s-maxage cache duration private readonly maxEntries = 250 private readonly writeThrottleMs = 60_000 // Throttle localStorage writes to once per 60s private lastWriteTime = 0 private pendingSaveTimer?: ReturnType<typeof setTimeout> constructor() { this.load() this.cleanup() } /** * Records that a shape received an up-to-date message with a specific cursor. * This timestamp and cursor are used to detect cache replay scenarios. * Updates in-memory immediately, but throttles localStorage writes. */ recordUpToDate(shapeKey: string, cursor: string): void { this.data[shapeKey] = { timestamp: Date.now(), cursor, } // Implement LRU eviction if we exceed max entries const keys = Object.keys(this.data) if (keys.length > this.maxEntries) { const oldest = keys.reduce((min, k) => this.data[k].timestamp < this.data[min].timestamp ? k : min ) delete this.data[oldest] } this.scheduleSave() } /** * Schedules a throttled save to localStorage. * Writes immediately if enough time has passed, otherwise schedules for later. */ private scheduleSave(): void { const now = Date.now() const timeSinceLastWrite = now - this.lastWriteTime if (timeSinceLastWrite >= this.writeThrottleMs) { // Enough time has passed, write immediately this.lastWriteTime = now this.save() } else if (!this.pendingSaveTimer) { // Schedule a write for when the throttle period expires const delay = this.writeThrottleMs - timeSinceLastWrite this.pendingSaveTimer = setTimeout(() => { this.lastWriteTime = Date.now() this.pendingSaveTimer = undefined this.save() }, delay) } // else: a save is already scheduled, no need to do anything } /** * Checks if we should enter replay mode for this shape. * Returns the last seen cursor if there's a recent up-to-date (< 60s), * which means we'll likely be replaying cached responses. * Returns null if no recent up-to-date exists. */ shouldEnterReplayMode(shapeKey: string): string | null { const entry = this.data[shapeKey] if (!entry) { return null } const age = Date.now() - entry.timestamp if (age >= this.cacheTTL) { return null } return entry.cursor } /** * Cleans up expired entries from the cache. * Called on initialization and can be called periodically. */ private cleanup(): void { const now = Date.now() const keys = Object.keys(this.data) let modified = false for (const key of keys) { const age = now - this.data[key].timestamp if (age > this.cacheTTL) { delete this.data[key] modified = true } } if (modified) { this.save() } } private save(): void { if (typeof localStorage === `undefined`) return try { localStorage.setItem(this.storageKey, JSON.stringify(this.data)) } catch { // Ignore localStorage errors (quota exceeded, etc.) } } private load(): void { if (typeof localStorage === `undefined`) return try { const stored = localStorage.getItem(this.storageKey) if (stored) { this.data = JSON.parse(stored) } } catch { // Ignore localStorage errors, start fresh this.data = {} } } /** * Clears all tracked up-to-date timestamps. * Useful for testing or manual cache invalidation. */ clear(): void { this.data = {} if (this.pendingSaveTimer) { clearTimeout(this.pendingSaveTimer) this.pendingSaveTimer = undefined } this.save() } delete(shapeKey: string): void { delete this.data[shapeKey] this.save() } } // Module-level singleton instance export const upToDateTracker = new UpToDateTracker()