From 676f41d76d0c1cf4ed9b13e562e406593a29b1ff Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:30:45 +0000 Subject: [PATCH] chore(ai): doc js/nostr/managers/EventsCacheStore.js (agent) Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- docs/events-cache-store-overview.md | 96 +++++++++++++++++++++++++++ js/nostr/managers/EventsCacheStore.js | 56 ++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 docs/events-cache-store-overview.md diff --git a/docs/events-cache-store-overview.md b/docs/events-cache-store-overview.md new file mode 100644 index 00000000..dd9e5a75 --- /dev/null +++ b/docs/events-cache-store-overview.md @@ -0,0 +1,96 @@ +# EventsCacheStore Overview + +The `EventsCacheStore` is a specialized persistence layer designed to handle the application's critical state: thousands of video events and their deletion status (tombstones). It uses **IndexedDB** as the primary storage backend, falling back to `localStorage` (via `PersistenceManager`) only if IDB is unavailable. + +## 1. Problem Statement + +Nostr clients must handle high-volume event streams. Storing every event naively to disk on every change causes: +- **Write Trashing**: Frequent, redundant writes degrade performance and battery life. +- **I/O Blocking**: Large JSON serializations on the main thread cause UI jank. +- **Startup Latency**: Reading thousands of small files or one giant blob is slow. + +## 2. Architecture: Incremental Persistence + +`EventsCacheStore` solves this with a **Differential State Snapshot** approach. + +### Key Concepts + +1. **Memory Fingerprints**: + - The store maintains a `Map` in memory, mirroring what is currently on disk. + - `Event Fingerprint`: `JSON.stringify(videoObject)` (fast enough for 1000s of items). + - `Tombstone Fingerprint`: `ts:`. + +2. **Debounced Snapshots**: + - `PersistenceManager` debounces writes (e.g., 75ms). When a write triggers, it passes the *entire* current state (`allEvents`) plus a set of `dirtyKeys` (IDs that changed since last save). + +3. **The Diff Algorithm (`_diffAndStore`)**: + - Instead of clearing and rewriting the DB, it iterates over the `dirtyKeys`. + - It computes the new fingerprint. + - **Optimization**: If `newFingerprint === persistedFingerprint`, the write is skipped. + - If different, it performs a `put()` operation and updates the memory fingerprint map. + +4. **Deletion Detection**: + - After processing updates, it compares the size of the memory state (`items.size`) vs the persisted state (`fingerprintsMap.size`). + - Since `fingerprintsMap` is updated during the loop to include new items, if `items.size < fingerprintsMap.size`, it implies some items in the persisted map are no longer in memory. + - In this case, it iterates the *entire* fingerprint map to find and `delete()` the missing keys. + +### Flow Diagram + +```mermaid +sequenceDiagram + participant App as NostrClient + participant PM as PersistenceManager + participant ECS as EventsCacheStore + participant IDB as IndexedDB + + App->>App: Event arrives (updates allEvents) + App->>PM: saveLocalData(reason, dirtyKeys) + PM->>PM: Debounce (75ms) + PM->>ECS: persistSnapshot(payload, dirtyKeys) + + activate ECS + ECS->>ECS: Ensure fingerprints loaded (once) + + loop For each Dirty Key + ECS->>ECS: Compute Fingerprint + alt Fingerprint Changed + ECS->>IDB: put(record) + ECS->>ECS: Update Fingerprint Map + else Same Fingerprint + ECS->>ECS: Skip + end + end + + alt items.size < fingerprintsMap.size + loop For each Persisted Key + alt Not in Memory + ECS->>IDB: delete(key) + ECS->>ECS: Remove from Fingerprint Map + end + end + end + + ECS-->>PM: { writes, deletes, skipped } + deactivate ECS +``` + +## 3. Data Schema + +The IndexedDB `bitvid-events-cache` (v1) contains three object stores: + +| Store | Key Path | Value Description | +| :--- | :--- | :--- | +| `events` | `id` | `{ id, video, fingerprint }` | +| `tombstones` | `key` | `{ key, timestamp, fingerprint }` | +| `meta` | `key` | `{ key: "meta", savedAt: , version: 1 }` | + +## 4. Invariants & Assumptions + +- **Single Threaded**: The store assumes it is the only writer. It does not handle cross-tab locking (though IDB transactions provide atomicity). +- **Memory-First**: The `allEvents` Map in memory is the source of truth. IDB is a cache. +- **TTL**: On `restoreSnapshot()`, if the data is older than the TTL (e.g. 10 minutes), the entire cache is wiped to ensure freshness. + +## 5. Maintenance + +- **Adding Fields**: If the `Video` object shape changes, the fingerprint (JSON string) automatically changes, forcing a rewrite. No migration needed for content changes. +- **Schema Changes**: To add a new store, increment `EVENTS_CACHE_DB_VERSION` and update `onupgradeneeded` in `getDb()`. diff --git a/js/nostr/managers/EventsCacheStore.js b/js/nostr/managers/EventsCacheStore.js index 77e4bf14..5645359e 100644 --- a/js/nostr/managers/EventsCacheStore.js +++ b/js/nostr/managers/EventsCacheStore.js @@ -42,6 +42,10 @@ function waitForTransaction(tx) { * - "Fingerprints" (hashes/stringified) are tracked in memory to minimize I/O. * - Only changed items (where fingerprint differs) are written to IDB. * - If the TTL expires, the cache is cleared on restore to force a fresh fetch. + * + * **Concurrency Note:** + * This class assumes single-threaded access (JavaScript main thread). + * It relies on `dirtyKeys` for optimization but falls back to full scans if needed. */ export class EventsCacheStore { /** @@ -59,6 +63,10 @@ export class EventsCacheStore { this.hasLoadedFingerprints = false; } + /** + * Checks if IndexedDB is available in the current environment. + * @returns {boolean} True if IndexedDB is supported. + */ isSupported() { return typeof indexedDB !== "undefined"; } @@ -101,6 +109,13 @@ export class EventsCacheStore { return this.dbPromise; } + /** + * Computes a unique fingerprint for a video object. + * Used to detect changes without deep comparison. + * + * @param {object} video - The video object to fingerprint. + * @returns {string} The computed fingerprint (JSON string). + */ computeEventFingerprint(video) { try { return JSON.stringify(video); @@ -110,6 +125,12 @@ export class EventsCacheStore { } } + /** + * Computes a fingerprint for a tombstone timestamp. + * + * @param {number} timestamp - The timestamp. + * @returns {string} The fingerprint. + */ computeTombstoneFingerprint(timestamp) { return `ts:${timestamp}`; } @@ -152,6 +173,11 @@ export class EventsCacheStore { this.hasLoadedFingerprints = true; } + /** + * Reads metadata from the store (version, savedAt). + * @param {IDBDatabase} db - The database instance. + * @returns {Promise} The metadata object. + */ async readMeta(db) { const tx = db.transaction(["meta"], "readonly"); const metaStore = tx.objectStore("meta"); @@ -228,6 +254,30 @@ export class EventsCacheStore { }; } + /** + * Core logic for incremental persistence. + * Compares items against `fingerprintsMap` to decide what to write/delete. + * + * **Optimization Logic:** + * 1. Iterates only `dirtyKeys` (if provided) or all `items`. + * 2. Computes current fingerprint. + * 3. Updates IDB only if fingerprint differs from `fingerprintsMap`. + * 4. Updates `fingerprintsMap` with the new fingerprint. + * 5. Checks if deletions are needed (if `items.size < fingerprintsMap.size`). + * - Note: `fingerprintsMap` grows during the loop as new items are added. + * - This ensures that if we deleted items from `items`, `items.size` will be smaller + * than `fingerprintsMap.size`, triggering the deletion scan. + * + * @param {object} params + * @param {IDBObjectStore} params.store - The IDB store to write to. + * @param {Map} params.items - The current state (Map of items). + * @param {Map} params.fingerprintsMap - The map of persisted fingerprints. + * @param {function} params.computeFingerprint - Helper to compute fingerprint. + * @param {Set|null} params.dirtyKeys - Optional set of keys that changed. + * @param {function} params.isValid - Validator for items. + * @param {function} params.createRecord - Helper to create IDB record object. + * @returns {{writes: number, deletes: number, skipped: number}} Stats. + */ _diffAndStore({ store, items, @@ -267,6 +317,12 @@ export class EventsCacheStore { writes++; } + // Deletion detection strategy: + // If dirtyKeys is null, we are doing a full scan, so check everything. + // If dirtyKeys is provided, we only check deletions if 'items' is smaller than our known persisted set. + // NOTE: fingerprintsMap is mutated in the loop above (adding new/updated items). + // So if items were removed from 'items' map, fingerprintsMap.size (which includes the removed ones) + // will be larger than items.size, correctly triggering the scan. const shouldScanDeletions = !dirtyKeys || items.size < fingerprintsMap.size; if (shouldScanDeletions) {