From 1bcb770556cd2499507e940d647746b83f911d2f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:42:21 -0400 Subject: [PATCH] Extend watch history cache TTL and add tests --- config/instance-config.js | 10 ++++ js/config.js | 2 + js/nostr.js | 103 ++++++++++++++++++++--------------- tests/watch-history.test.mjs | 73 +++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 43 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index 2586c026..4c45d063 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -103,3 +103,13 @@ export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000; * so the UI can stitch the full list back together. */ export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12; + +/** + * How long clients should cache watch-history snapshots in localStorage. + * + * A 24-hour window keeps recently played videos available across reloads + * without requiring a fresh relay sync on every visit. Increase the value for + * longer-lived caches or decrease it if your deployment needs tighter + * retention guarantees. + */ +export const WATCH_HISTORY_CACHE_TTL_MS = 24 * 60 * 60 * 1000; diff --git a/js/config.js b/js/config.js index 460f5adb..705f48f7 100644 --- a/js/config.js +++ b/js/config.js @@ -10,6 +10,7 @@ import { WATCH_HISTORY_BATCH_RESOLVE, WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, + WATCH_HISTORY_CACHE_TTL_MS, } from "../config/instance-config.js"; export const isDevMode = true; // Set to false for production @@ -59,3 +60,4 @@ export { WATCH_HISTORY_MAX_ITEMS }; export { WATCH_HISTORY_BATCH_RESOLVE }; export { WATCH_HISTORY_PAYLOAD_MAX_BYTES }; export { WATCH_HISTORY_FETCH_EVENT_LIMIT }; +export { WATCH_HISTORY_CACHE_TTL_MS }; diff --git a/js/nostr.js b/js/nostr.js index 08647f28..2f944d75 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -8,6 +8,7 @@ import { WATCH_HISTORY_BATCH_RESOLVE, WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, + WATCH_HISTORY_CACHE_TTL_MS, } from "./config.js"; import { ACCEPT_LEGACY_V1 } from "./constants.js"; import { accessControl } from "./accessControl.js"; @@ -32,7 +33,6 @@ const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1"; -const WATCH_HISTORY_CACHE_TTL_MS = EVENTS_CACHE_TTL_MS; // To limit error spam let errorLogCount = 0; @@ -861,6 +861,7 @@ class NostrClient { this.watchHistoryCache = new Map(); this.watchHistoryStorage = null; this.watchHistoryRepublishTimers = new Map(); + this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS; } restoreLocalData() { @@ -962,60 +963,72 @@ class NostrClient { return this.allEvents.size > 0; } + /** + * Resolve the effective TTL for watch-history cache entries. + */ + getWatchHistoryCacheTtlMs() { + const candidate = this.watchHistoryCacheTtlMs; + if (Number.isFinite(candidate) && candidate >= 0) { + return candidate; + } + return WATCH_HISTORY_CACHE_TTL_MS; + } + /** * Load watch history cache payload from localStorage. */ getWatchHistoryStorage() { - if (this.watchHistoryStorage) { - return this.watchHistoryStorage; - } - const base = { version: 1, actors: {} }; - if (typeof localStorage === "undefined") { - this.watchHistoryStorage = base; - return this.watchHistoryStorage; - } + if (!this.watchHistoryStorage) { + if (typeof localStorage === "undefined") { + this.watchHistoryStorage = base; + return this.watchHistoryStorage; + } - let parsed = base; - try { - const raw = localStorage.getItem(WATCH_HISTORY_CACHE_STORAGE_KEY); - if (raw) { - const candidate = JSON.parse(raw); - if ( - candidate && - typeof candidate === "object" && - candidate.version === 1 && - candidate.actors && - typeof candidate.actors === "object" - ) { - parsed = { version: 1, actors: candidate.actors }; + let parsed = base; + try { + const raw = localStorage.getItem(WATCH_HISTORY_CACHE_STORAGE_KEY); + if (raw) { + const candidate = JSON.parse(raw); + if ( + candidate && + typeof candidate === "object" && + candidate.version === 1 && + candidate.actors && + typeof candidate.actors === "object" + ) { + parsed = { version: 1, actors: candidate.actors }; + } + } + } catch (error) { + if (isDevMode) { + console.warn("[nostr] Failed to parse watch history cache:", error); } } - } catch (error) { - if (isDevMode) { - console.warn("[nostr] Failed to parse watch history cache:", error); - } + + const actors = { ...(parsed.actors || {}) }; + this.watchHistoryStorage = { version: 1, actors }; } const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); let mutated = false; - for (const [actor, info] of Object.entries(parsed.actors || {})) { + const actors = this.watchHistoryStorage.actors || {}; + for (const actor of Object.keys(actors)) { + const info = actors[actor]; if ( !info || typeof info !== "object" || typeof info.savedAt !== "number" || - now - info.savedAt > WATCH_HISTORY_CACHE_TTL_MS + now - info.savedAt > ttl ) { - delete parsed.actors[actor]; + delete actors[actor]; mutated = true; } } - const actors = { ...(parsed.actors || {}) }; - this.watchHistoryStorage = { version: 1, actors }; - - if (mutated) { + if (mutated && typeof localStorage !== "undefined") { try { localStorage.setItem( WATCH_HISTORY_CACHE_STORAGE_KEY, @@ -1936,16 +1949,20 @@ class NostrClient { } const now = Date.now(); + const ttl = this.getWatchHistoryCacheTtlMs(); const cached = this.watchHistoryCache.get(actor); - if (cached && now - cached.savedAt < WATCH_HISTORY_CACHE_TTL_MS) { - return { - pointerEvent: cached.pointerEvent - ? cloneEventForCache(cached.pointerEvent) - : null, - items: cached.items - .map((item) => clonePointerItem(item)) - .filter(Boolean), - }; + if (cached) { + if (now - cached.savedAt < ttl) { + return { + pointerEvent: cached.pointerEvent + ? cloneEventForCache(cached.pointerEvent) + : null, + items: cached.items + .map((item) => clonePointerItem(item)) + .filter(Boolean), + }; + } + this.watchHistoryCache.delete(actor); } let storage = null; @@ -1956,7 +1973,7 @@ class NostrClient { if ( stored && typeof stored.savedAt === "number" && - now - stored.savedAt < WATCH_HISTORY_CACHE_TTL_MS + now - stored.savedAt < ttl ) { const entry = this.createWatchHistoryEntry( stored.pointerEvent, diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 0d4dadf0..338c39f5 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -158,4 +158,77 @@ assert.deepEqual( "chunked fetch should preserve pointer order" ); +localStorage.clear(); + +const originalDateNow = Date.now; +try { + const baseTime = 1_700_000_000_000; + const ttlActor = `${ACTOR}-ttl`; + const pointer = { type: "e", value: "event-ttl", relay: null }; + + const cachingClient = createDecryptClient(ttlActor); + cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000; + + Date.now = () => baseTime; + + const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime); + cachingClient.watchHistoryCache.set(ttlActor, entry); + cachingClient.persistWatchHistoryEntry(ttlActor, entry); + + const reloadClient = createDecryptClient(ttlActor); + reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs; + + Date.now = () => baseTime + 60 * 60 * 1000; + + const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor); + assert.equal( + cachedSnapshot.items.length, + 1, + "cached snapshot should survive within extended TTL" + ); + assert.equal( + cachedSnapshot.items[0].value, + pointer.value, + "cached snapshot should preserve pointer" + ); + + localStorage.clear(); + + const expiryClient = createDecryptClient(ttlActor); + expiryClient.watchHistoryCacheTtlMs = 1_000; + + Date.now = () => baseTime; + + const expiringEntry = expiryClient.createWatchHistoryEntry( + null, + [pointer], + baseTime + ); + expiryClient.watchHistoryCache.set(ttlActor, expiringEntry); + expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry); + + Date.now = () => baseTime + 1_500; + + const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor); + assert.equal( + expiredSnapshot.items.length, + 0, + "expired snapshot should be dropped" + ); + const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor); + assert.equal( + cachedFallback?.items?.length ?? 0, + 0, + "expired snapshot should not preserve stale items in memory" + ); + const storage = expiryClient.getWatchHistoryStorage(); + assert.equal( + Object.prototype.hasOwnProperty.call(storage.actors, ttlActor), + false, + "expired snapshot should be removed from localStorage" + ); +} finally { + Date.now = originalDateNow; +} + console.log("watch history tests passed");