diff --git a/js/adminListStore.js b/js/adminListStore.js index a43f2034..c08e8bcf 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -17,7 +17,9 @@ import { devLogger, userLogger } from "./utils/logger.js"; import { buildAdminListEvent, ADMIN_LIST_IDENTIFIERS, + NOTE_TYPES, } from "./nostrEventSchemas.js"; +import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js"; import { publishEventToRelay } from "./nostrPublish.js"; const ADMIN_STATE_CACHE_VERSION = 1; @@ -231,6 +233,11 @@ function hasAnyEntries(state = {}) { } export const readCachedAdminState = () => { + const policy = CACHE_POLICIES[NOTE_TYPES.ADMIN_MODERATION_LIST]; + if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { + return null; + } + if (typeof localStorage === "undefined") { return null; } @@ -263,6 +270,11 @@ export const readCachedAdminState = () => { }; export const writeCachedAdminState = (state) => { + const policy = CACHE_POLICIES[NOTE_TYPES.ADMIN_MODERATION_LIST]; + if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { + return; + } + if (typeof localStorage === "undefined") { return; } diff --git a/js/nostr/cachePolicies.js b/js/nostr/cachePolicies.js new file mode 100644 index 00000000..d923d9ba --- /dev/null +++ b/js/nostr/cachePolicies.js @@ -0,0 +1,87 @@ +import { NOTE_TYPES } from "../nostrEventSchemas.js"; + +export const STORAGE_TIERS = Object.freeze({ + MEMORY: "memory", + INDEXED_DB: "indexedDB", + LOCAL_STORAGE: "localStorage", +}); + +export const MERGE_STRATEGIES = Object.freeze({ + REPLACEABLE: "replaceable", + APPEND_ONLY: "append-only", +}); + +export const CACHE_POLICIES = Object.freeze({ + [NOTE_TYPES.VIDEO_POST]: { + storage: STORAGE_TIERS.INDEXED_DB, + ttl: 10 * 60 * 1000, // 10 minutes + addressing: "kind:pubkey:d", + merge: MERGE_STRATEGIES.REPLACEABLE, + }, + [NOTE_TYPES.WATCH_HISTORY]: { + storage: STORAGE_TIERS.LOCAL_STORAGE, + ttl: 24 * 60 * 60 * 1000, // 24 hours + addressing: "kind:pubkey:d", + merge: MERGE_STRATEGIES.APPEND_ONLY, // Bucketed append + }, + [NOTE_TYPES.SUBSCRIPTION_LIST]: { + storage: STORAGE_TIERS.LOCAL_STORAGE, + ttl: Infinity, // Manual management + addressing: "kind:pubkey:d", + merge: MERGE_STRATEGIES.REPLACEABLE, + }, + [NOTE_TYPES.USER_BLOCK_LIST]: { + storage: STORAGE_TIERS.LOCAL_STORAGE, + ttl: Infinity, // Manual management + addressing: "kind:pubkey:d", + merge: MERGE_STRATEGIES.REPLACEABLE, + }, + [NOTE_TYPES.RELAY_LIST]: { + storage: STORAGE_TIERS.LOCAL_STORAGE, + ttl: Infinity, // Manual management + addressing: "kind:pubkey", + merge: MERGE_STRATEGIES.REPLACEABLE, + }, + [NOTE_TYPES.ADMIN_MODERATION_LIST]: { + storage: STORAGE_TIERS.LOCAL_STORAGE, + ttl: Infinity, + addressing: "kind:pubkey:d", + merge: MERGE_STRATEGIES.REPLACEABLE, + }, + [NOTE_TYPES.ADMIN_BLACKLIST]: { + storage: STORAGE_TIERS.LOCAL_STORAGE, + ttl: Infinity, + addressing: "kind:pubkey:d", + merge: MERGE_STRATEGIES.REPLACEABLE, + }, + [NOTE_TYPES.ADMIN_WHITELIST]: { + storage: STORAGE_TIERS.LOCAL_STORAGE, + ttl: Infinity, + addressing: "kind:pubkey:d", + merge: MERGE_STRATEGIES.REPLACEABLE, + }, + [NOTE_TYPES.VIDEO_COMMENT]: { + storage: STORAGE_TIERS.MEMORY, + ttl: 0, // Session only + addressing: "id", + merge: MERGE_STRATEGIES.APPEND_ONLY, + }, + [NOTE_TYPES.VIDEO_REACTION]: { + storage: STORAGE_TIERS.MEMORY, + ttl: 0, + addressing: "id", + merge: MERGE_STRATEGIES.APPEND_ONLY, + }, + [NOTE_TYPES.VIEW_EVENT]: { + storage: STORAGE_TIERS.MEMORY, + ttl: 0, + addressing: "id", + merge: MERGE_STRATEGIES.APPEND_ONLY, + }, + [NOTE_TYPES.HASHTAG_PREFERENCES]: { + storage: STORAGE_TIERS.LOCAL_STORAGE, + ttl: Infinity, + addressing: "kind:pubkey:d", + merge: MERGE_STRATEGIES.REPLACEABLE, + }, +}); diff --git a/js/nostr/client.js b/js/nostr/client.js index 8eef5d78..b8b1828e 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -60,6 +60,7 @@ import { getNostrEventSchema, NOTE_TYPES, } from "../nostrEventSchemas.js"; +import { CACHE_POLICIES } from "./cachePolicies.js"; import { listVideoViewEvents as listVideoViewEventsForClient, subscribeVideoViewEvents as subscribeVideoViewEventsForClient, @@ -314,7 +315,9 @@ function shouldRequestExtensionPermissions(signer) { } const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; -const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes +// We use the policy TTL, but currently the storage backend is hardcoded to IDB (with localStorage fallback). +// Future refactors should make EventsCacheStore dynamic based on CACHE_POLICIES[NOTE_TYPES.VIDEO_POST].storage. +const EVENTS_CACHE_TTL_MS = CACHE_POLICIES[NOTE_TYPES.VIDEO_POST]?.ttl ?? (10 * 60 * 1000); const EVENTS_CACHE_DB_NAME = "bitvid-events-cache"; const EVENTS_CACHE_DB_VERSION = 1; const EVENTS_CACHE_PERSIST_DELAY_MS = 450; diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 40b85c3a..9e03147f 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -11,7 +11,9 @@ import { } from "../config.js"; import { buildWatchHistoryEvent, + NOTE_TYPES, } from "../nostrEventSchemas.js"; +import { CACHE_POLICIES } from "./cachePolicies.js"; import { publishEventToRelays } from "../nostrPublish.js"; import { RELAY_URLS, @@ -960,11 +962,17 @@ class WatchHistoryManager { if (Number.isFinite(this.cacheTtlMs) && this.cacheTtlMs > 0) { return this.cacheTtlMs; } + const policyTtl = CACHE_POLICIES[NOTE_TYPES.WATCH_HISTORY]?.ttl; const configured = Number(WATCH_HISTORY_CACHE_TTL_MS); - const resolved = + + // Prefer config if set (for backward compat or overrides), otherwise policy + let resolved = Number.isFinite(configured) && configured > 0 ? Math.floor(configured) - : 24 * 60 * 60 * 1000; + : Number.isFinite(policyTtl) && policyTtl > 0 + ? policyTtl + : 24 * 60 * 60 * 1000; + this.cacheTtlMs = resolved; return resolved; } diff --git a/js/relayManager.js b/js/relayManager.js index 92d7625d..0278ad79 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -5,7 +5,8 @@ import { requestDefaultExtensionPermissions, } from "./nostrClientFacade.js"; import { getActiveSigner } from "./nostr/index.js"; -import { buildRelayListEvent } from "./nostrEventSchemas.js"; +import { buildRelayListEvent, NOTE_TYPES } from "./nostrEventSchemas.js"; +import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { publishEventToRelays, @@ -17,6 +18,7 @@ const MODE_SEQUENCE = ["both", "read", "write"]; const FAST_RELAY_FETCH_LIMIT = 3; const FAST_RELAY_TIMEOUT_MS = 2500; const BACKGROUND_RELAY_TIMEOUT_MS = 6000; +const RELAY_PREFS_STORAGE_KEY = "bitvid:relay-preferences:v1"; function normalizeHexPubkey(pubkey) { if (typeof pubkey !== "string") { @@ -191,7 +193,47 @@ class RelayPreferencesManager { const normalized = normalizeRelayUrl(url) || url; return createEntry(normalized, "both"); }); - this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true }); + + // Attempt to load from storage if policy allows + const loaded = this.loadFromStorage(); + if (loaded && loaded.length) { + this.setEntries(loaded, { allowEmpty: false, updateClient: true }); + } else { + this.setEntries(this.defaultEntries, { allowEmpty: false, updateClient: true }); + } + } + + loadFromStorage() { + const policy = CACHE_POLICIES[NOTE_TYPES.RELAY_LIST]; + if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { + return null; + } + if (typeof localStorage === "undefined") return null; + try { + const raw = localStorage.getItem(RELAY_PREFS_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed; + } + } + } catch (err) { + devLogger.warn("[RelayPreferencesManager] Failed to load from storage", err); + } + return null; + } + + saveToStorage() { + const policy = CACHE_POLICIES[NOTE_TYPES.RELAY_LIST]; + if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { + return; + } + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(RELAY_PREFS_STORAGE_KEY, serializeEntries(this.entries)); + } catch (err) { + devLogger.warn("[RelayPreferencesManager] Failed to save to storage", err); + } } snapshot() { @@ -279,6 +321,7 @@ class RelayPreferencesManager { this.syncClient(); } + this.saveToStorage(); return this.getEntries(); } diff --git a/js/subscriptions.js b/js/subscriptions.js index 2eba1e8c..63835cd8 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -18,6 +18,7 @@ import { getNostrEventSchema, NOTE_TYPES, } from "./nostrEventSchemas.js"; +import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { publishEventToRelays, @@ -277,6 +278,10 @@ class SubscriptionsManager { } loadFromCache(userPubkey) { + const policy = CACHE_POLICIES[NOTE_TYPES.SUBSCRIPTION_LIST]; + if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { + return null; + } if (typeof localStorage === "undefined") return null; const key = this.getCacheKey(userPubkey); if (!key) return null; @@ -294,6 +299,10 @@ class SubscriptionsManager { } saveToCache(userPubkey) { + const policy = CACHE_POLICIES[NOTE_TYPES.SUBSCRIPTION_LIST]; + if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { + return; + } if (typeof localStorage === "undefined") return; const key = this.getCacheKey(userPubkey); if (!key) return; diff --git a/js/userBlocks.js b/js/userBlocks.js index 36b840c0..4e9f5df1 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -4,7 +4,8 @@ import { requestDefaultExtensionPermissions, } from "./nostrClientFacade.js"; import { getActiveSigner } from "./nostr/index.js"; -import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js"; +import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER, NOTE_TYPES } from "./nostrEventSchemas.js"; +import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { publishEventToRelays, @@ -1448,10 +1449,18 @@ class UserBlockListManager { } _loadLocal(actorHex) { + const policy = CACHE_POLICIES[NOTE_TYPES.USER_BLOCK_LIST]; + if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { + return null; + } return readLocalBlocks(actorHex); } _saveLocal(actorHex) { + const policy = CACHE_POLICIES[NOTE_TYPES.USER_BLOCK_LIST]; + if (policy?.storage !== STORAGE_TIERS.LOCAL_STORAGE) { + return; + } writeLocalBlocks(actorHex, this.blockedPubkeys); } diff --git a/tests/nostr/cachePolicies.test.mjs b/tests/nostr/cachePolicies.test.mjs new file mode 100644 index 00000000..f15409b7 --- /dev/null +++ b/tests/nostr/cachePolicies.test.mjs @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { CACHE_POLICIES, STORAGE_TIERS, MERGE_STRATEGIES } from '../../js/nostr/cachePolicies.js'; +import { NOTE_TYPES } from '../../js/nostrEventSchemas.js'; + +test('CACHE_POLICIES structure', () => { + assert.ok(CACHE_POLICIES, 'CACHE_POLICIES should be defined'); + assert.ok(STORAGE_TIERS, 'STORAGE_TIERS should be defined'); + assert.ok(MERGE_STRATEGIES, 'MERGE_STRATEGIES should be defined'); +}); + +test('VIDEO_POST policy', () => { + const policy = CACHE_POLICIES[NOTE_TYPES.VIDEO_POST]; + assert.ok(policy, 'VIDEO_POST policy should exist'); + assert.strictEqual(policy.storage, STORAGE_TIERS.INDEXED_DB); + assert.strictEqual(policy.ttl, 10 * 60 * 1000); + assert.strictEqual(policy.merge, MERGE_STRATEGIES.REPLACEABLE); +}); + +test('WATCH_HISTORY policy', () => { + const policy = CACHE_POLICIES[NOTE_TYPES.WATCH_HISTORY]; + assert.ok(policy, 'WATCH_HISTORY policy should exist'); + assert.strictEqual(policy.storage, STORAGE_TIERS.LOCAL_STORAGE); + assert.strictEqual(policy.ttl, 24 * 60 * 60 * 1000); + assert.strictEqual(policy.merge, MERGE_STRATEGIES.APPEND_ONLY); +}); + +test('SUBSCRIPTION_LIST policy', () => { + const policy = CACHE_POLICIES[NOTE_TYPES.SUBSCRIPTION_LIST]; + assert.ok(policy, 'SUBSCRIPTION_LIST policy should exist'); + assert.strictEqual(policy.storage, STORAGE_TIERS.LOCAL_STORAGE); + assert.strictEqual(policy.ttl, Infinity); +}); + +test('VIDEO_COMMENT policy', () => { + const policy = CACHE_POLICIES[NOTE_TYPES.VIDEO_COMMENT]; + assert.ok(policy, 'VIDEO_COMMENT policy should exist'); + assert.strictEqual(policy.storage, STORAGE_TIERS.MEMORY); +});