chore(ai): doc js/nostr/managers/EventsCacheStore.js (agent)

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-11 15:30:45 +00:00
1 parent d218baae4d
commit 676f41d76d
2 files changed
+152

No files matched your search

+96
View File
@@ -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<ID, Fingerprint>` in memory, mirroring what is currently on disk.
- `Event Fingerprint`: `JSON.stringify(videoObject)` (fast enough for 1000s of items).
- `Tombstone Fingerprint`: `ts:<timestamp>`.
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: <ts>, 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()`.
+56
View File
@@ -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<object|undefined>} 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<string>|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) {