From daf9336772313fca7c4057b28b87431f4f759792 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 30 Mar 2026 09:18:37 +0200 Subject: [PATCH] make electron app session storage persistent --- package-lock.json | 4 +- package.json | 2 +- src/components/CacheRelaysSetting/index.tsx | 10 +- .../EventArchiveCacheSettings/index.tsx | 167 +++++++ src/constants.ts | 8 + src/i18n/locales/en.ts | 19 + src/lib/client-platform.ts | 15 + src/lib/event-archive-config.ts | 83 ++++ src/lib/piper-tts-cache-policy.ts | 31 ++ src/lib/read-aloud.ts | 35 +- .../secondary/CacheSettingsPage/index.tsx | 2 + src/services/client-events.service.ts | 43 +- src/services/client.service.ts | 55 +++ src/services/event-archive.service.ts | 131 ++++++ src/services/indexed-db.service.ts | 423 +++++++++++++++++- 15 files changed, 1010 insertions(+), 18 deletions(-) create mode 100644 src/components/EventArchiveCacheSettings/index.tsx create mode 100644 src/lib/client-platform.ts create mode 100644 src/lib/event-archive-config.ts create mode 100644 src/lib/piper-tts-cache-policy.ts create mode 100644 src/services/event-archive.service.ts diff --git a/package-lock.json b/package-lock.json index d6f86b22..2ea3d8d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "21.1.2", + "version": "21.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "21.1.2", + "version": "21.2.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 2a41cd1e..77e1f98b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "21.1.2", + "version": "21.2.0", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/CacheRelaysSetting/index.tsx b/src/components/CacheRelaysSetting/index.tsx index 791b9178..2f50a1a6 100644 --- a/src/components/CacheRelaysSetting/index.tsx +++ b/src/components/CacheRelaysSetting/index.tsx @@ -33,7 +33,7 @@ import { CloudUpload, Trash2, RefreshCw, Database, WrapText, Search, X, Triangle import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import client from '@/services/client.service' -import indexedDb from '@/services/indexed-db.service' +import indexedDb, { StoreNames } from '@/services/indexed-db.service' import postEditorCache from '@/services/post-editor-cache.service' import { StorageKey } from '@/constants' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' @@ -216,8 +216,9 @@ export default function CacheRelaysSetting() { } try { - // Clear IndexedDB + // Clear IndexedDB (all stores, including Piper read-aloud blobs) await indexedDb.clearAllCache() + await indexedDb.clearPiperTtsCache() // Clear localStorage (but keep essential settings like theme, accounts, etc.) // We'll only clear Jumble-specific cache keys, not all localStorage @@ -726,6 +727,11 @@ export default function CacheRelaysSetting() { // If neither exists, it's invalid return true } + + if (storeName === StoreNames.PIPER_TTS_CACHE) { + const v = item.value as { blob?: unknown; mimeType?: string } | null + return !(v && typeof v.mimeType === 'string' && v.blob instanceof Blob) + } // For other stores, check if value exists if (!item.value) return true diff --git a/src/components/EventArchiveCacheSettings/index.tsx b/src/components/EventArchiveCacheSettings/index.tsx new file mode 100644 index 00000000..07fc6f24 --- /dev/null +++ b/src/components/EventArchiveCacheSettings/index.tsx @@ -0,0 +1,167 @@ +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Input } from '@/components/ui/input' +import { StorageKey } from '@/constants' +import { + EVENT_ARCHIVE_DEFAULTS, + getEventArchiveConfig +} from '@/lib/event-archive-config' +import { isJumbleElectron, isMobileBrowserProfile } from '@/lib/client-platform' +import client from '@/services/client.service' +import { invalidateArchiveFootprintCache } from '@/services/event-archive.service' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +function platformLabel(): string { + if (isJumbleElectron()) return 'desktop-app' + if (isMobileBrowserProfile()) return 'mobile-web' + return 'desktop-web' +} + +export default function EventArchiveCacheSettings() { + const { t } = useTranslation() + const [enabled, setEnabled] = useState(true) + const [maxMb, setMaxMb] = useState('') + const [maxEvents, setMaxEvents] = useState('') + const [sessionLru, setSessionLru] = useState('') + + const defaultsHint = useMemo(() => { + const p = platformLabel() + if (p === 'mobile-web') { + return t('eventArchive.defaultsMobile', { + lru: EVENT_ARCHIVE_DEFAULTS.sessionLruMobile, + mb: EVENT_ARCHIVE_DEFAULTS.maxMbMobile, + ev: EVENT_ARCHIVE_DEFAULTS.maxEventsMobile + }) + } + if (p === 'desktop-app') { + return t('eventArchive.defaultsElectron', { + lru: EVENT_ARCHIVE_DEFAULTS.sessionLruElectron, + mb: EVENT_ARCHIVE_DEFAULTS.maxMbElectron, + ev: EVENT_ARCHIVE_DEFAULTS.maxEventsElectron + }) + } + return t('eventArchive.defaultsDesktopWeb', { + lru: EVENT_ARCHIVE_DEFAULTS.sessionLruDesktopBrowser, + mb: EVENT_ARCHIVE_DEFAULTS.maxMbDesktopBrowser, + ev: EVENT_ARCHIVE_DEFAULTS.maxEventsDesktopBrowser + }) + }, [t]) + + useEffect(() => { + setEnabled(window.localStorage.getItem(StorageKey.EVENT_ARCHIVE_ENABLED) !== 'false') + setMaxMb(window.localStorage.getItem(StorageKey.EVENT_ARCHIVE_MAX_MB) ?? '') + setMaxEvents(window.localStorage.getItem(StorageKey.EVENT_ARCHIVE_MAX_EVENTS) ?? '') + setSessionLru(window.localStorage.getItem(StorageKey.SESSION_EVENT_LRU_MAX) ?? '') + }, []) + + const apply = useCallback(() => { + window.localStorage.setItem(StorageKey.EVENT_ARCHIVE_ENABLED, enabled ? 'true' : 'false') + const mb = maxMb.trim() + if (mb) window.localStorage.setItem(StorageKey.EVENT_ARCHIVE_MAX_MB, mb) + else window.localStorage.removeItem(StorageKey.EVENT_ARCHIVE_MAX_MB) + const ev = maxEvents.trim() + if (ev) window.localStorage.setItem(StorageKey.EVENT_ARCHIVE_MAX_EVENTS, ev) + else window.localStorage.removeItem(StorageKey.EVENT_ARCHIVE_MAX_EVENTS) + const lru = sessionLru.trim() + if (lru) window.localStorage.setItem(StorageKey.SESSION_EVENT_LRU_MAX, lru) + else window.localStorage.removeItem(StorageKey.SESSION_EVENT_LRU_MAX) + client.reapplySessionLruFromSettings() + invalidateArchiveFootprintCache() + toast.success(t('eventArchive.appliedToast')) + }, [enabled, maxMb, maxEvents, sessionLru, t]) + + const effective = getEventArchiveConfig() + + return ( +
+

{t('eventArchive.sectionTitle')}

+

{t('eventArchive.sectionBlurb')}

+

{defaultsHint}

+ +
+ + setEnabled(Boolean(v))} + /> +
+ +
+
+ + setMaxMb(e.target.value)} + /> +
+
+ + setMaxEvents(e.target.value)} + /> +
+
+ +
+ + setSessionLru(e.target.value)} + /> +
+ +

+ {t('eventArchive.effectiveSummary', { + enabled: effective.enabled ? t('eventArchive.on') : t('eventArchive.off'), + mb: Math.round(effective.maxBytes / (1024 * 1024)), + events: effective.maxEvents, + lru: effective.sessionLruMax + })} +

+ + +
+ ) +} diff --git a/src/constants.ts b/src/constants.ts index 4784f3ad..949d592a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -162,6 +162,14 @@ export const StorageKey = { SHOW_PUBLISH_SUCCESS_TOASTS: 'showPublishSuccessToasts', /** When not `'false'`, show NIP-53 live activity banner (default on). */ SHOW_LIVE_ACTIVITIES_BANNER: 'showLiveActivitiesBanner', + /** Persist timeline notes/reactions to IndexedDB (platform defaults; disable for relay-only). */ + EVENT_ARCHIVE_ENABLED: 'eventArchiveEnabled', + /** Max approximate archive size (MB). `0` in UI means “use platform default”. */ + EVENT_ARCHIVE_MAX_MB: 'eventArchiveMaxMb', + /** Max rows in event archive. `0` means use platform default. */ + EVENT_ARCHIVE_MAX_EVENTS: 'eventArchiveMaxEvents', + /** In-memory session LRU max (events). Platform default if unset. */ + SESSION_EVENT_LRU_MAX: 'sessionEventLruMax', /** Temporary draft cache: new notes and replies. Persisted after 30s idle; restored on refresh; cleared on logout/switch. */ POST_EDITOR_DRAFT: 'postEditorDraft', MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index aaf29806..a2b6fa34 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -625,6 +625,25 @@ export default { successes: 'successes', None: 'None', 'Cache & offline storage': 'Cache & offline storage', + 'eventArchive.sectionTitle': 'Notes & feed archive', + 'eventArchive.sectionBlurb': + 'Keeps notes, reactions, and timeline order on disk so feeds can load offline or on slow links. Replaceable data (profiles, relay lists, publications) stays in its existing stores — this archive only fills gaps for “firehose” events. Turn off to rely on relays only.', + 'eventArchive.defaultsMobile': + 'This device profile uses small defaults: about {{lru}} events in memory, ~{{mb}} MB / {{ev}} archived events (reactions/zaps drop first).', + 'eventArchive.defaultsElectron': + 'Desktop app defaults: ~{{lru}} in-memory events, ~{{mb}} MB / {{ev}} archived events.', + 'eventArchive.defaultsDesktopWeb': + 'Desktop browser defaults: ~{{lru}} in-memory events, ~{{mb}} MB / {{ev}} archived events.', + 'eventArchive.enablePersist': 'Persist feed events to disk', + 'eventArchive.maxMb': 'Max archive size (MB), blank = default for this device', + 'eventArchive.maxEvents': 'Max archived events, blank = default', + 'eventArchive.sessionLru': 'In-memory session cache (event count), blank = default', + 'eventArchive.effectiveSummary': + 'Currently: {{enabled}} — ~{{mb}} MB budget, {{events}} events, {{lru}} session LRU.', + 'eventArchive.on': 'on', + 'eventArchive.off': 'off', + 'eventArchive.apply': 'Apply cache settings', + 'eventArchive.appliedToast': 'Cache settings saved. Session memory updated.', 'Paste or drop media files to upload': 'Paste or drop media files to upload', Preview: 'Preview', 'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?': diff --git a/src/lib/client-platform.ts b/src/lib/client-platform.ts new file mode 100644 index 00000000..f08581fd --- /dev/null +++ b/src/lib/client-platform.ts @@ -0,0 +1,15 @@ +/** True when running inside the packaged Electron shell ({@link electron/preload.cjs}). */ +export function isJumbleElectron(): boolean { + return typeof window !== 'undefined' && window.jumbleElectron?.isElectron === true +} + +/** + * Coarse “phone / mobile browser” profile: touch-first or narrow viewport, excluding Electron. + * Used for smaller in-memory LRU and tighter disk archive defaults (not a substitute for real UA tests). + */ +export function isMobileBrowserProfile(): boolean { + if (typeof window === 'undefined' || isJumbleElectron()) return false + const narrow = window.matchMedia?.('(max-width: 768px)')?.matches ?? false + const coarse = window.matchMedia?.('(pointer: coarse)')?.matches ?? false + return narrow || (coarse && (window.innerWidth ?? 1024) <= 900) +} diff --git a/src/lib/event-archive-config.ts b/src/lib/event-archive-config.ts new file mode 100644 index 00000000..0de30e23 --- /dev/null +++ b/src/lib/event-archive-config.ts @@ -0,0 +1,83 @@ +import { StorageKey } from '@/constants' +import { isJumbleElectron, isMobileBrowserProfile } from '@/lib/client-platform' + +/** Platform defaults (overridable in Cache settings). */ +export const EVENT_ARCHIVE_DEFAULTS = { + sessionLruMobile: 100, + sessionLruDesktopBrowser: 2500, + sessionLruElectron: 5000, + maxMbMobile: 48, + maxMbElectron: 512, + maxMbDesktopBrowser: 2048, + maxEventsMobile: 500, + maxEventsElectron: 400_000, + maxEventsDesktopBrowser: 80_000 +} as const + +export type TEventArchiveConfig = { + enabled: boolean + /** Soft byte budget (approximate, from JSON size). */ + maxBytes: number + maxEvents: number + sessionLruMax: number +} + +function readBool(key: string, defaultTrue: boolean): boolean { + try { + const v = window.localStorage.getItem(key) + if (v === null) return defaultTrue + return v !== 'false' && v !== '0' + } catch { + return defaultTrue + } +} + +function readPositiveInt(key: string, fallback: number): number { + try { + const v = window.localStorage.getItem(key) + if (v === null || v === '' || v === '0') return fallback + const n = Number.parseInt(v, 10) + return Number.isFinite(n) && n > 0 ? n : fallback + } catch { + return fallback + } +} + +function defaultSessionLruMax(): number { + if (isJumbleElectron()) return EVENT_ARCHIVE_DEFAULTS.sessionLruElectron + if (isMobileBrowserProfile()) return EVENT_ARCHIVE_DEFAULTS.sessionLruMobile + return EVENT_ARCHIVE_DEFAULTS.sessionLruDesktopBrowser +} + +function defaultMaxMb(): number { + if (isJumbleElectron()) return EVENT_ARCHIVE_DEFAULTS.maxMbElectron + if (isMobileBrowserProfile()) return EVENT_ARCHIVE_DEFAULTS.maxMbMobile + return EVENT_ARCHIVE_DEFAULTS.maxMbDesktopBrowser +} + +function defaultMaxEvents(): number { + if (isJumbleElectron()) return EVENT_ARCHIVE_DEFAULTS.maxEventsElectron + if (isMobileBrowserProfile()) return EVENT_ARCHIVE_DEFAULTS.maxEventsMobile + return EVENT_ARCHIVE_DEFAULTS.maxEventsDesktopBrowser +} + +/** + * Effective archive + session LRU limits (reads Cache settings from localStorage). + */ +export function getEventArchiveConfig(): TEventArchiveConfig { + const enabled = readBool(StorageKey.EVENT_ARCHIVE_ENABLED, true) + const maxMb = readPositiveInt(StorageKey.EVENT_ARCHIVE_MAX_MB, defaultMaxMb()) + const maxEvents = readPositiveInt(StorageKey.EVENT_ARCHIVE_MAX_EVENTS, defaultMaxEvents()) + const sessionLruMax = readPositiveInt(StorageKey.SESSION_EVENT_LRU_MAX, defaultSessionLruMax()) + return { + enabled, + maxBytes: Math.max(8, maxMb) * 1024 * 1024, + maxEvents: Math.max(50, maxEvents), + sessionLruMax: Math.max(32, Math.min(200_000, sessionLruMax)) + } +} + +/** Session LRU max before localStorage overrides (for EventService constructor). */ +export function getDefaultSessionLruMaxSync(): number { + return readPositiveInt(StorageKey.SESSION_EVENT_LRU_MAX, defaultSessionLruMax()) +} diff --git a/src/lib/piper-tts-cache-policy.ts b/src/lib/piper-tts-cache-policy.ts new file mode 100644 index 00000000..9fc1ec37 --- /dev/null +++ b/src/lib/piper-tts-cache-policy.ts @@ -0,0 +1,31 @@ +import { isJumbleElectron, isMobileBrowserProfile } from '@/lib/client-platform' + +/** How long we keep Piper WAV blobs (per device class). */ +export function getPiperTtsCacheTtlMs(): number { + if (isJumbleElectron()) return 7 * 24 * 60 * 60 * 1000 + if (isMobileBrowserProfile()) return 24 * 60 * 60 * 1000 + return 48 * 60 * 60 * 1000 +} + +/** Caps so TTS audio cannot grow without bound (evicts oldest after TTL pass). */ +export function getPiperTtsCacheBudget(): { maxEntries: number; maxBytes: number } { + if (isJumbleElectron()) return { maxEntries: 400, maxBytes: 400 * 1024 * 1024 } + if (isMobileBrowserProfile()) return { maxEntries: 80, maxBytes: 45 * 1024 * 1024 } + return { maxEntries: 200, maxBytes: 180 * 1024 * 1024 } +} + +/** + * Stable key for a Piper request: same URL + text + speed → same audio. + * Server upgrades / voice changes require a new endpoint URL or speed to bust the cache. + */ +export async function buildPiperTtsCacheKey( + endpointUrl: string, + text: string, + speed: number +): Promise { + const payload = new TextEncoder().encode(JSON.stringify({ u: endpointUrl, t: text, s: speed })) + const digest = await crypto.subtle.digest('SHA-256', payload) + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} diff --git a/src/lib/read-aloud.ts b/src/lib/read-aloud.ts index bbcf4e57..f957402e 100644 --- a/src/lib/read-aloud.ts +++ b/src/lib/read-aloud.ts @@ -1,4 +1,10 @@ import { ExtendedKind, READ_ALOUD_TTS_URL } from '@/constants' +import { + buildPiperTtsCacheKey, + getPiperTtsCacheBudget, + getPiperTtsCacheTtlMs +} from '@/lib/piper-tts-cache-policy' +import indexedDb from '@/services/indexed-db.service' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' import { Event, kinds } from 'nostr-tools' @@ -294,12 +300,26 @@ async function fetchPiperTtsBlobForChunk( throw new Error(`Part ${chunkIndex + 1} of ${totalChunks}: TTS URL not configured`) } + const speed = 1 + const ttlMs = getPiperTtsCacheTtlMs() + const budget = getPiperTtsCacheBudget() + let cacheKey: string | undefined + try { + cacheKey = await buildPiperTtsCacheKey(url, text, speed) + const hit = await indexedDb.getPiperTtsBlobCache(cacheKey, ttlMs) + if (hit && hit.size > 0) { + return hit + } + } catch { + /* IndexedDB or crypto unavailable — fetch without cache */ + } + let response: Response try { response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, speed: 1 }), + body: JSON.stringify({ text, speed }), signal }) } catch (e) { @@ -328,6 +348,19 @@ async function fetchPiperTtsBlobForChunk( throw new Error(`Part ${chunkIndex + 1} of ${totalChunks}: empty audio response`) } + if (cacheKey) { + try { + const mime = blob.type || response.headers.get('Content-Type') || 'audio/wav' + await indexedDb.putPiperTtsBlobCache(cacheKey, blob, mime, { + ttlMs, + maxEntries: budget.maxEntries, + maxBytes: budget.maxBytes + }) + } catch { + /* cache write failure should not break playback */ + } + } + return blob } diff --git a/src/pages/secondary/CacheSettingsPage/index.tsx b/src/pages/secondary/CacheSettingsPage/index.tsx index be69a4b2..90550075 100644 --- a/src/pages/secondary/CacheSettingsPage/index.tsx +++ b/src/pages/secondary/CacheSettingsPage/index.tsx @@ -1,4 +1,5 @@ import CacheRelaysSetting from '@/components/CacheRelaysSetting' +import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings' import { RefreshButton } from '@/components/RefreshButton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' @@ -30,6 +31,7 @@ const CacheSettingsPage = forwardRef( >
+
) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 4803a285..b45c6ace 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -19,6 +19,13 @@ import { LRUCache } from 'lru-cache' import indexedDb from './indexed-db.service' import type { QueryService } from './client-query.service' import client from './client.service' +import { + invalidateArchiveFootprintCache, + loadArchivedEventForFetch, + prefetchArchivedEvents, + queuePersistSeenEvent +} from './event-archive.service' +import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' @@ -54,8 +61,8 @@ export class EventService { * In-memory session cache: events seen this tab session (timelines, queries, fetches). * Larger cap + no TTL so navigation and repeat fetches reuse data until reload. */ - /** Large cap: timelines + note-stats (reactions, replies, zaps, reposts per note) share one LRU. */ - private sessionEventCache = new LRUCache({ max: 5_000 }) + /** Timelines + note-stats; cap is platform-aware (see Cache settings). */ + private sessionEventCache = new LRUCache({ max: getDefaultSessionLruMaxSync() }) /** Latest kind-0 per pubkey from {@link sessionEventCache} for batch profile short-circuit. */ private sessionMetadataByPubkey = new Map() /** Callbacks waiting for an event id to appear in {@link sessionEventCache} (e.g. embed loads before timeline caches the note). */ @@ -248,7 +255,14 @@ export class EventService { .filter((id) => /^[0-9a-f]{64}$/.test(id)) ) ] - const toFetch = hexIds.filter((id) => !this.getSessionEventIfAllowed(id)) + let toFetch = hexIds.filter((id) => !this.getSessionEventIfAllowed(id)) + if (toFetch.length === 0) return + + const archived = await prefetchArchivedEvents(toFetch) + for (const ev of archived) { + if (!shouldDropEventOnIngest(ev)) this.addEventToCache(ev) + } + toFetch = toFetch.filter((id) => !this.getSessionEventIfAllowed(id)) if (toFetch.length === 0) return const relayUrls = await buildComprehensiveRelayListForEvents(undefined, [], [], []) @@ -367,6 +381,17 @@ export class EventService { } } this.notifySessionEventWaiters(id) + queuePersistSeenEvent(cleanEvent as NEvent) + } + + /** Apply {@link StorageKey.SESSION_EVENT_LRU_MAX} without reload (copies entries into a new LRU). */ + reapplySessionLruMax(): void { + const max = getDefaultSessionLruMaxSync() + const entries = [...this.sessionEventCache.entries()] + this.sessionEventCache = new LRUCache({ max }) + for (const [k, v] of entries) { + this.sessionEventCache.set(k, v) + } } /** Kind 0 already ingested this session (e.g. from a timeline REQ). */ @@ -600,6 +625,7 @@ export class EventService { this.eventCacheMap.clear() this.sessionEventWaiters.clear() this.fetchEventFromBigRelaysDataloader.clearAll() + invalidateArchiveFootprintCache() logger.info('[EventService] In-memory caches cleared') } @@ -638,6 +664,17 @@ export class EventService { if (!filter) return undefined + if (filter.ids?.length === 1) { + const hid = filter.ids[0]!.toLowerCase() + if (/^[0-9a-f]{64}$/.test(hid)) { + const fromArchive = await loadArchivedEventForFetch(hid) + if (fromArchive && !shouldDropEventOnIngest(fromArchive)) { + this.addEventToCache(fromArchive) + return fromArchive + } + } + } + // Try cache first if (filter.ids?.length) { const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0]) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 826d89ac..6b739df0 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -73,6 +73,7 @@ import { } from 'nostr-tools' import { AbstractRelay } from 'nostr-tools/abstract-relay' import indexedDb from './indexed-db.service' +import { invalidateArchiveFootprintCache } from './event-archive.service' import { notifyLiveActivitiesPrewarmComplete } from './live-activities-prewarm-bridge' import nip66Service from './nip66.service' import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-strike' @@ -173,6 +174,7 @@ class ClientService extends EventTarget { | string[] | undefined > = {} + private timelinePersistTimers = new Map>() /** In-flight {@link fetchRelayList} dedupe: key = viewer pubkey + target pubkey (sanitization depends on viewer). */ private relayListRequestCache = new Map>() private userIndex = new FlexSearch.Index({ @@ -1439,6 +1441,30 @@ class ClientService extends EventTarget { /** =========== Timeline =========== */ + private scheduleTimelinePersist(timelineKey: string): void { + const prev = this.timelinePersistTimers.get(timelineKey) + if (prev) clearTimeout(prev) + const t = setTimeout(() => { + this.timelinePersistTimers.delete(timelineKey) + void this.flushTimelinePersist(timelineKey) + }, 1600) + this.timelinePersistTimers.set(timelineKey, t) + } + + private async flushTimelinePersist(timelineKey: string): Promise { + const tl = this.timelines[timelineKey] + if (!tl || Array.isArray(tl) || !tl.refs?.length) return + try { + await indexedDb.putTimelinePersistedState(timelineKey, { + refs: [...tl.refs], + filter: { ...(tl.filter as object) } as Record, + urls: [...tl.urls] + }) + } catch (e) { + logger.warn('[ClientService] Timeline persist failed', { timelineKey: timelineKey.slice(0, 12), e }) + } + } + private generateTimelineKey(urls: string[], filter: Filter) { const stableFilter: any = {} Object.entries(filter) @@ -2089,6 +2115,24 @@ class ClientService extends EventTarget { } } + void (async () => { + try { + const st = await indexedDb.getTimelinePersistedState(key) + if (!st?.refs?.length) return + const list = await indexedDb.getArchivedEventsByIds(st.refs.map((r) => r[0])) + if (list.length === 0) return + for (const ev of list) { + if (shouldDropEventOnIngest(ev)) continue + if (eventIds.has(ev.id)) continue + eventIds.add(ev.id) + events.push(ev) + } + flushStreamingSnapshot() + } catch (err) { + logger.warn('[ClientService] Timeline disk hydrate failed', err) + } + })() + const handleTimelineEose = (eosed: boolean) => { if (!eosed) return if (eosedAt != null) return @@ -2125,6 +2169,7 @@ class ClientService extends EventTarget { } } onEvents([...events], true) + that.scheduleTimelinePersist(key) } const subCloser = this.subscribe(relays, filter, { @@ -2161,6 +2206,7 @@ class ClientService extends EventTarget { timeline.refs = events .map((e) => [e.id, e.created_at] as TTimelineRef) .sort((a, b) => b[1] - a[1]) + that.scheduleTimelinePersist(key) } return } @@ -2175,6 +2221,7 @@ class ClientService extends EventTarget { if (timeline.refs.length === 0) { timeline.refs = events.map((e) => [e.id, e.created_at] as TTimelineRef).sort((a, b) => b[1] - a[1]) + that.scheduleTimelinePersist(key) return } @@ -2191,6 +2238,7 @@ class ClientService extends EventTarget { if (idx >= timeline.refs.length) return timeline.refs.splice(idx, 0, [evt.id, evt.created_at]) + that.scheduleTimelinePersist(key) }, oneose: handleTimelineEose, onclose: onClose @@ -2247,6 +2295,7 @@ class ClientService extends EventTarget { timeline.refs.push(...newRefs) } + this.scheduleTimelinePersist(key) return events } @@ -2420,6 +2469,10 @@ class ClientService extends EventTarget { this.eventService.addEventToCache(event) } + reapplySessionLruFromSettings(): void { + this.eventService.reapplySessionLruMax() + } + peekSessionCachedEvent(noteId: string): NEvent | undefined { return this.eventService.peekSessionCachedEvent(noteId) } @@ -2548,6 +2601,7 @@ class ClientService extends EventTarget { if (removed > 0) { logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) } + invalidateArchiveFootprintCache() dispatchTombstonesUpdated() } @@ -2613,6 +2667,7 @@ class ClientService extends EventTarget { count: removed }) } + invalidateArchiveFootprintCache() dispatchTombstonesUpdated() } } catch (e) { diff --git a/src/services/event-archive.service.ts b/src/services/event-archive.service.ts new file mode 100644 index 00000000..eb383489 --- /dev/null +++ b/src/services/event-archive.service.ts @@ -0,0 +1,131 @@ +import { ExtendedKind } from '@/constants' +import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { getEventArchiveConfig } from '@/lib/event-archive-config' +import { isNip18RepostKind, isNip25ReactionKind, isReplaceableEvent } from '@/lib/event' +import logger from '@/lib/logger' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' +import indexedDb from '@/services/indexed-db.service' + +/** “Primary” notes / threads — evicted last. */ +const CORE_FEED_KINDS = new Set([ + kinds.ShortTextNote, + 11, + ExtendedKind.COMMENT, + 20, + 21, + 22, + 9802 // highlights +]) + +let footprint: { count: number; bytes: number } | null = null +const pending = new Map() +let flushTimer: ReturnType | null = null + +export function invalidateArchiveFootprintCache(): void { + footprint = null +} + +async function ensureFootprint(): Promise { + if (footprint === null) { + footprint = await indexedDb.getArchiveFootprint() + } +} + +function archiveTierForEvent(ev: Event): number { + if (isNip25ReactionKind(ev.kind) || ev.kind === kinds.Zap || isNip18RepostKind(ev.kind)) { + return 0 + } + if (CORE_FEED_KINDS.has(ev.kind)) return 2 + return 1 +} + +function shouldSkipArchiving(ev: Event): boolean { + if (shouldDropEventOnIngest(ev)) return true + if (isReplaceableEvent(ev.kind) && indexedDb.hasReplaceableEventStoreForKind(ev.kind)) { + return true + } + return false +} + +function approxEventBytes(ev: Event): number { + try { + return new Blob([JSON.stringify(ev)]).size + } catch { + return 512 + } +} + +async function trimArchiveIfNeeded(): Promise { + const cfg = getEventArchiveConfig() + if (!cfg.enabled) return + await ensureFootprint() + let guard = 0 + while ( + footprint !== null && + guard < 5000 && + (footprint.count > cfg.maxEvents || footprint.bytes > cfg.maxBytes) + ) { + guard++ + const victim = await indexedDb.deleteNextEvictionArchiveCandidate() + if (!victim) { + footprint = await indexedDb.getArchiveFootprint() + break + } + footprint.count = Math.max(0, footprint.count - 1) + footprint.bytes = Math.max(0, footprint.bytes - victim.approxBytes) + } +} + +async function flushArchiveQueue(): Promise { + const cfg = getEventArchiveConfig() + if (!cfg.enabled || pending.size === 0) return + const batch = [...pending.values()] + pending.clear() + for (const ev of batch) { + if (shouldSkipArchiving(ev)) continue + const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id + const tier = archiveTierForEvent(ev) + const bytes = approxEventBytes(ev) + try { + await indexedDb.putArchivedEventRow(ev, tier, bytes) + } catch (e) { + logger.warn('[EventArchive] put failed', { id: id.slice(0, 8), e }) + } + } + footprint = await indexedDb.getArchiveFootprint() + await trimArchiveIfNeeded() +} + +function scheduleFlush(): void { + if (flushTimer !== null) return + flushTimer = setTimeout(() => { + flushTimer = null + void flushArchiveQueue().catch((e) => logger.warn('[EventArchive] flush', e)) + }, 450) +} + +/** Queue a non-replaceable event for IndexedDB archive (Electron + mobile + desktop web; caps differ). */ +export function queuePersistSeenEvent(ev: Event): void { + const cfg = getEventArchiveConfig() + if (!cfg.enabled) return + if (shouldSkipArchiving(ev)) return + const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id + if (!/^[0-9a-f]{64}$/.test(id)) return + pending.set(id, ev) + scheduleFlush() +} + +export async function loadArchivedEventForFetch(hexId: string): Promise { + const cfg = getEventArchiveConfig() + if (!cfg.enabled) return undefined + const ev = await indexedDb.getArchivedEventById(hexId, true) + if (!ev || shouldDropEventOnIngest(ev)) return undefined + return ev +} + +export async function prefetchArchivedEvents(hexIds: string[]): Promise { + const cfg = getEventArchiveConfig() + if (!cfg.enabled || hexIds.length === 0) return [] + return indexedDb.getArchivedEventsByIds(hexIds) +} diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 34ee711f..6b7cd1ed 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -10,6 +10,28 @@ import { kinds } from 'nostr-tools' import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event' import logger from '@/lib/logger' +/** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */ +export type TArchivedEventRow = { + key: string + value: Event + addedAt: number + lastAccessAt: number + approxBytes: number + archiveTier: number +} + +/** Persisted feed state for cold-start (filter JSON must round-trip). */ +export type TTimelinePersistedPayload = { + refs: [string, number][] + filter: Record + urls: string[] +} + +export type TPiperTtsCacheValue = { + blob: Blob + mimeType: string +} + type TValue = { key: string value: T | null @@ -58,11 +80,17 @@ export const StoreNames = { /** Tombstone list for deleted events (kind 5). Key: event id or replaceable coordinate. */ TOMBSTONE_LIST: 'tombstoneList', /** NIP-58 badge definitions (kind 30009). Key: pubkey:d */ - BADGE_DEFINITION_EVENTS: 'badgeDefinitionEvents' + BADGE_DEFINITION_EVENTS: 'badgeDefinitionEvents', + /** Hot timeline / REQ events (non-replaceable kinds not stored elsewhere). Key: event id hex. */ + EVENT_ARCHIVE: 'eventArchive', + /** Persisted timeline refs + filter for cold-start hydration. Key: {@link ClientService.generateTimelineKey} hash. */ + TIMELINE_STATE: 'timelineState', + /** Piper / read-aloud WAV blobs keyed by SHA-256 of endpoint + text + speed. */ + PIPER_TTS_CACHE: 'piperTtsCache' } /** Schema version we expect. When adding stores or migrations, bump this. */ -const DB_VERSION = 31 +const DB_VERSION = 33 /** Max age for profile and payment info cache before we refetch (5 min). */ const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 @@ -83,6 +111,9 @@ function ensureMissingObjectStores(db: IDBDatabase): void { const store = db.createObjectStore(storeName, { keyPath: 'key' }) store.createIndex('feedUrl', 'feedUrl', { unique: false }) store.createIndex('pubDate', 'pubDate', { unique: false }) + } else if (storeName === StoreNames.EVENT_ARCHIVE) { + const store = db.createObjectStore(storeName, { keyPath: 'key' }) + store.createIndex('eviction', ['archiveTier', 'lastAccessAt'], { unique: false }) } else { db.createObjectStore(storeName, { keyPath: 'key' }) } @@ -260,6 +291,16 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.BADGE_DEFINITION_EVENTS)) { db.createObjectStore(StoreNames.BADGE_DEFINITION_EVENTS, { keyPath: 'key' }) } + if (!db.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) { + const arc = db.createObjectStore(StoreNames.EVENT_ARCHIVE, { keyPath: 'key' }) + arc.createIndex('eviction', ['archiveTier', 'lastAccessAt'], { unique: false }) + } + if (!db.objectStoreNames.contains(StoreNames.TIMELINE_STATE)) { + db.createObjectStore(StoreNames.TIMELINE_STATE, { keyPath: 'key' }) + } + if (!db.objectStoreNames.contains(StoreNames.PIPER_TTS_CACHE)) { + db.createObjectStore(StoreNames.PIPER_TTS_CACHE, { keyPath: 'key' }) + } ensureMissingObjectStores(db) } } @@ -1234,6 +1275,10 @@ class IndexedDbService { } } + /** + * Clears every object store in the `jumble` database, including + * {@link StoreNames.PIPER_TTS_CACHE} (read-aloud / Piper WAV blobs). + */ async clearAllCache(): Promise { await this.initPromise if (!this.db) { @@ -1336,6 +1381,11 @@ class IndexedDbService { }) } + /** Clear cached Piper / read-aloud audio blobs. No-op if the store is absent. */ + async clearPiperTtsCache(): Promise { + await this.clearStore(StoreNames.PIPER_TTS_CACHE) + } + async clearStore(storeName: string): Promise { await this.initPromise if (!this.db || !this.db.objectStoreNames.contains(storeName)) { @@ -2102,6 +2152,362 @@ class IndexedDbService { }) } + /** Hot archive row (kinds already persisted in replaceable stores should not use this). */ + async putArchivedEventRow( + event: Event, + archiveTier: number, + approxBytes: number + ): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return + const id = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id + const clean = { ...event } + delete (clean as any).relayStatuses + const now = Date.now() + const row: TArchivedEventRow = { + key: id, + value: clean as Event, + addedAt: now, + lastAccessAt: now, + approxBytes: Math.max(80, approxBytes), + archiveTier + } + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readwrite') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const put = store.put(row) + put.onsuccess = () => { + tx.commit() + resolve() + } + put.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async touchArchivedEventAccess(eventId: string): Promise { + const id = eventId.toLowerCase() + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readwrite') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const get = store.get(id) + get.onsuccess = () => { + const row = get.result as TArchivedEventRow | undefined + if (!row?.value) { + tx.commit() + resolve() + return + } + row.lastAccessAt = Date.now() + const put = store.put(row) + put.onsuccess = () => { + tx.commit() + resolve() + } + put.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + } + get.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async getArchivedEventById(eventId: string, touchAccess: boolean): Promise { + const id = eventId.toLowerCase() + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return undefined + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, touchAccess ? 'readwrite' : 'readonly') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const get = store.get(id) + get.onsuccess = () => { + const row = get.result as TArchivedEventRow | undefined + const ev = row?.value + if (touchAccess && row && ev) { + row.lastAccessAt = Date.now() + const put = store.put(row) + put.onsuccess = () => { + tx.commit() + resolve(ev) + } + put.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + return + } + tx.commit() + resolve(ev) + } + get.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async getArchivedEventsByIds(ids: string[]): Promise { + const uniq = [...new Set(ids.map((x) => x.toLowerCase()))].filter((x) => /^[0-9a-f]{64}$/.test(x)) + if (uniq.length === 0) return [] + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] + const out: Event[] = [] + await Promise.all( + uniq.map( + (id) => + new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') + const get = tx.objectStore(StoreNames.EVENT_ARCHIVE).get(id) + get.onsuccess = () => { + const row = get.result as TArchivedEventRow | undefined + if (row?.value) out.push(row.value) + tx.commit() + resolve() + } + get.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + ) + ) + return out + } + + async deleteArchivedEvent(eventId: string): Promise { + const id = eventId.toLowerCase() + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readwrite') + const del = tx.objectStore(StoreNames.EVENT_ARCHIVE).delete(id) + del.onsuccess = () => { + tx.commit() + resolve() + } + del.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + /** Delete lowest (tier, then oldest access) row for archive eviction. */ + async deleteNextEvictionArchiveCandidate(): Promise<{ id: string; approxBytes: number } | null> { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return null + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readwrite') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const idx = store.index('eviction') + const req = idx.openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve(null) + return + } + const row = cursor.value as TArchivedEventRow + const id = row.key + const approxBytes = row.approxBytes ?? 0 + cursor.delete() + tx.commit() + resolve({ id, approxBytes }) + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async getArchiveFootprint(): Promise<{ count: number; bytes: number }> { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) { + return { count: 0, bytes: 0 } + } + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const req = store.openCursor() + let count = 0 + let bytes = 0 + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve({ count, bytes }) + return + } + const row = cursor.value as TArchivedEventRow + count++ + bytes += row.approxBytes ?? 0 + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async putTimelinePersistedState( + timelineKey: string, + payload: TTimelinePersistedPayload + ): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.TIMELINE_STATE)) return + const row = this.formatValue(timelineKey, payload) + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.TIMELINE_STATE, 'readwrite') + const put = tx.objectStore(StoreNames.TIMELINE_STATE).put(row) + put.onsuccess = () => { + tx.commit() + resolve() + } + put.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async getTimelinePersistedState(timelineKey: string): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.TIMELINE_STATE)) return null + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.TIMELINE_STATE, 'readonly') + const get = tx.objectStore(StoreNames.TIMELINE_STATE).get(timelineKey) + get.onsuccess = () => { + const row = get.result as TValue | undefined + tx.commit() + resolve(row?.value ?? null) + } + get.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async getPiperTtsBlobCache(cacheKey: string, ttlMs: number): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.PIPER_TTS_CACHE)) return null + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.PIPER_TTS_CACHE, 'readwrite') + const store = tx.objectStore(StoreNames.PIPER_TTS_CACHE) + const get = store.get(cacheKey) + get.onsuccess = () => { + const row = get.result as TValue | undefined + if (!row?.value?.blob) { + tx.commit() + resolve(null) + return + } + if (Date.now() - row.addedAt > ttlMs) { + const del = store.delete(cacheKey) + del.onsuccess = () => { + tx.commit() + resolve(null) + } + del.onerror = () => { + tx.commit() + resolve(null) + } + return + } + tx.commit() + resolve(row.value.blob) + } + get.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + + async putPiperTtsBlobCache( + cacheKey: string, + blob: Blob, + mimeType: string, + opts: { ttlMs: number; maxEntries: number; maxBytes: number } + ): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.PIPER_TTS_CACHE)) return + const row = this.formatValue(cacheKey, { blob, mimeType }) + await new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.PIPER_TTS_CACHE, 'readwrite') + const put = tx.objectStore(StoreNames.PIPER_TTS_CACHE).put(row) + put.onsuccess = () => { + tx.commit() + resolve() + } + put.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + await this.prunePiperTtsBlobCache(opts.ttlMs, opts.maxEntries, opts.maxBytes) + } + + /** Drop expired Piper blobs, then oldest rows until under entry / byte caps. */ + async prunePiperTtsBlobCache(ttlMs: number, maxEntries: number, maxBytes: number): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.PIPER_TTS_CACHE)) return + const now = Date.now() + const rows: Array<{ key: string; addedAt: number; bytes: number }> = [] + + await new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.PIPER_TTS_CACHE, 'readonly') + const req = tx.objectStore(StoreNames.PIPER_TTS_CACHE).openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor) { + tx.commit() + resolve() + return + } + const row = cursor.value as TValue + const key = cursor.key as string + const bytes = row.value?.blob?.size ?? 0 + rows.push({ key, addedAt: row.addedAt, bytes }) + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + + const toDelete = new Set() + for (const r of rows) { + if (now - r.addedAt > ttlMs) toDelete.add(r.key) + } + const survivors = rows.filter((r) => !toDelete.has(r.key)).sort((a, b) => a.addedAt - b.addedAt) + let totalBytes = survivors.reduce((s, r) => s + r.bytes, 0) + let totalCount = survivors.length + while (totalCount > maxEntries || totalBytes > maxBytes) { + const victim = survivors.shift() + if (!victim) break + toDelete.add(victim.key) + totalBytes -= victim.bytes + totalCount-- + } + + for (const key of toDelete) { + await this.deleteStoreItem(StoreNames.PIPER_TTS_CACHE, key) + } + } + /** * Get all tombstoned keys */ @@ -2149,13 +2555,12 @@ class IndexedDbService { // Or just event ID for non-replaceable events const parts = key.split(':') if (parts.length === 1) { - // Event ID - remove from publication store - try { - await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key) - removed++ - } catch { - // Ignore errors - } + // Event ID - remove from publication store + hot archive + await Promise.allSettled([ + this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key), + this.deleteArchivedEvent(key) + ]) + removed++ } else if (parts.length >= 2) { // Replaceable coordinate: kind:64-hex-pubkey[:d...] (d may contain ':' per NIP-33) const kind = parseInt(parts[0]!, 10)