mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactor persistSnapshot to use generic diffAndStore helper
Extracts the duplicate logic for diffing and persisting events and tombstones in `EventsCacheStore` into a private `_diffAndStore` helper method. This improves maintainability and reduces code duplication. Also adds a new test file `tests/nostr/eventsCacheStore.test.mjs` to verify the functionality of `EventsCacheStore` using `fake-indexeddb`. Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
58412220fc
commit
e697d89946
2 files changed
+215
-105
No files matched your search
@@ -228,6 +228,61 @@ export class EventsCacheStore {
|
||||
};
|
||||
}
|
||||
|
||||
_diffAndStore({
|
||||
store,
|
||||
items,
|
||||
fingerprintsMap,
|
||||
computeFingerprint,
|
||||
dirtyKeys,
|
||||
isValid,
|
||||
createRecord,
|
||||
}) {
|
||||
let writes = 0;
|
||||
let deletes = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const iterable =
|
||||
dirtyKeys instanceof Set
|
||||
? Array.from(dirtyKeys).map((key) => [key, items.get(key)])
|
||||
: items.entries();
|
||||
|
||||
for (const [key, value] of iterable) {
|
||||
if (!isValid(key, value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dirtyKeys && !dirtyKeys.has(key) && fingerprintsMap.has(key)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fingerprint = computeFingerprint(value);
|
||||
const prevFingerprint = fingerprintsMap.get(key);
|
||||
if (prevFingerprint === fingerprint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
store.put(createRecord(key, value, fingerprint));
|
||||
fingerprintsMap.set(key, fingerprint);
|
||||
writes++;
|
||||
}
|
||||
|
||||
const shouldScanDeletions = !dirtyKeys || items.size < fingerprintsMap.size;
|
||||
|
||||
if (shouldScanDeletions) {
|
||||
for (const persistedKey of Array.from(fingerprintsMap.keys())) {
|
||||
if (items.has(persistedKey)) {
|
||||
continue;
|
||||
}
|
||||
store.delete(persistedKey);
|
||||
fingerprintsMap.delete(persistedKey);
|
||||
deletes++;
|
||||
}
|
||||
}
|
||||
|
||||
return { writes, deletes, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the current state to IndexedDB.
|
||||
* Only writes changes (diffs against fingerprints).
|
||||
@@ -238,7 +293,11 @@ export class EventsCacheStore {
|
||||
* @returns {Promise<{persisted: boolean, eventWrites: number, eventDeletes: number, tombstoneWrites: number, tombstoneDeletes: number}>}
|
||||
* Stats about the persistence operation.
|
||||
*/
|
||||
async persistSnapshot(payload, dirtyEventIds = null, dirtyTombstoneKeys = null) {
|
||||
async persistSnapshot(
|
||||
payload,
|
||||
dirtyEventIds = null,
|
||||
dirtyTombstoneKeys = null
|
||||
) {
|
||||
const db = await this.getDb();
|
||||
if (!db) {
|
||||
return { persisted: false };
|
||||
@@ -252,107 +311,30 @@ export class EventsCacheStore {
|
||||
const metaStore = tx.objectStore("meta");
|
||||
|
||||
const { events, tombstones, savedAt } = payload;
|
||||
let eventWrites = 0;
|
||||
let eventDeletes = 0;
|
||||
let tombstoneWrites = 0;
|
||||
let tombstoneDeletes = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Optimization: If dirtyEventIds is provided, only check those events.
|
||||
// Otherwise, iterate over all events in the payload.
|
||||
const eventsIterable =
|
||||
dirtyEventIds instanceof Set
|
||||
? Array.from(dirtyEventIds).map((id) => [id, events.get(id)])
|
||||
: events.entries();
|
||||
const eventsResult = this._diffAndStore({
|
||||
store: eventsStore,
|
||||
items: events,
|
||||
fingerprintsMap: this.persistedEventFingerprints,
|
||||
computeFingerprint: (v) => this.computeEventFingerprint(v),
|
||||
dirtyKeys: dirtyEventIds,
|
||||
isValid: (id, video) => id && video,
|
||||
createRecord: (id, video, fingerprint) => ({ id, video, fingerprint }),
|
||||
});
|
||||
|
||||
for (const [id, video] of eventsIterable) {
|
||||
if (!id || !video) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
dirtyEventIds &&
|
||||
!dirtyEventIds.has(id) &&
|
||||
this.persistedEventFingerprints.has(id)
|
||||
) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fingerprint = this.computeEventFingerprint(video);
|
||||
const prevFingerprint = this.persistedEventFingerprints.get(id);
|
||||
if (prevFingerprint === fingerprint) {
|
||||
continue;
|
||||
}
|
||||
eventsStore.put({ id, video, fingerprint });
|
||||
this.persistedEventFingerprints.set(id, fingerprint);
|
||||
eventWrites++;
|
||||
}
|
||||
|
||||
// Optimization: Skip deletion scan if we are processing partial updates (dirtyEventIds)
|
||||
// AND the total number of events hasn't decreased. This avoids checking 'has()' on thousands of items.
|
||||
const shouldScanEventDeletions =
|
||||
!dirtyEventIds || events.size < this.persistedEventFingerprints.size;
|
||||
|
||||
if (shouldScanEventDeletions) {
|
||||
for (const persistedId of Array.from(
|
||||
this.persistedEventFingerprints.keys()
|
||||
)) {
|
||||
if (events.has(persistedId)) {
|
||||
continue;
|
||||
}
|
||||
eventsStore.delete(persistedId);
|
||||
this.persistedEventFingerprints.delete(persistedId);
|
||||
eventDeletes++;
|
||||
}
|
||||
}
|
||||
|
||||
// Optimization: If dirtyTombstoneKeys is provided, only check those.
|
||||
const tombstonesIterable =
|
||||
dirtyTombstoneKeys instanceof Set
|
||||
? Array.from(dirtyTombstoneKeys).map((key) => [key, tombstones.get(key)])
|
||||
: tombstones.entries();
|
||||
|
||||
for (const [key, timestamp] of tombstonesIterable) {
|
||||
if (!key || !Number.isFinite(timestamp)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
dirtyTombstoneKeys &&
|
||||
!dirtyTombstoneKeys.has(key) &&
|
||||
this.persistedTombstoneFingerprints.has(key)
|
||||
) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fingerprint = this.computeTombstoneFingerprint(timestamp);
|
||||
const prevFingerprint = this.persistedTombstoneFingerprints.get(key);
|
||||
if (prevFingerprint === fingerprint) {
|
||||
continue;
|
||||
}
|
||||
tombstoneStore.put({ key, timestamp, fingerprint });
|
||||
this.persistedTombstoneFingerprints.set(key, fingerprint);
|
||||
tombstoneWrites++;
|
||||
}
|
||||
|
||||
const shouldScanTombstoneDeletions =
|
||||
!dirtyTombstoneKeys ||
|
||||
tombstones.size < this.persistedTombstoneFingerprints.size;
|
||||
|
||||
if (shouldScanTombstoneDeletions) {
|
||||
for (const persistedKey of Array.from(
|
||||
this.persistedTombstoneFingerprints.keys()
|
||||
)) {
|
||||
if (tombstones.has(persistedKey)) {
|
||||
continue;
|
||||
}
|
||||
tombstoneStore.delete(persistedKey);
|
||||
this.persistedTombstoneFingerprints.delete(persistedKey);
|
||||
tombstoneDeletes++;
|
||||
}
|
||||
}
|
||||
const tombstonesResult = this._diffAndStore({
|
||||
store: tombstoneStore,
|
||||
items: tombstones,
|
||||
fingerprintsMap: this.persistedTombstoneFingerprints,
|
||||
computeFingerprint: (v) => this.computeTombstoneFingerprint(v),
|
||||
dirtyKeys: dirtyTombstoneKeys,
|
||||
isValid: (key, timestamp) => key && Number.isFinite(timestamp),
|
||||
createRecord: (key, timestamp, fingerprint) => ({
|
||||
key,
|
||||
timestamp,
|
||||
fingerprint,
|
||||
}),
|
||||
});
|
||||
|
||||
metaStore.put({ key: "meta", savedAt, version: 1 });
|
||||
|
||||
@@ -360,11 +342,11 @@ export class EventsCacheStore {
|
||||
|
||||
return {
|
||||
persisted: true,
|
||||
eventWrites,
|
||||
eventDeletes,
|
||||
tombstoneWrites,
|
||||
tombstoneDeletes,
|
||||
skipped,
|
||||
eventWrites: eventsResult.writes,
|
||||
eventDeletes: eventsResult.deletes,
|
||||
tombstoneWrites: tombstonesResult.writes,
|
||||
tombstoneDeletes: tombstonesResult.deletes,
|
||||
skipped: eventsResult.skipped + tombstonesResult.skipped,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { indexedDB } from "fake-indexeddb";
|
||||
// Polyfill global indexedDB
|
||||
if (typeof global.indexedDB === 'undefined') {
|
||||
global.indexedDB = indexedDB;
|
||||
}
|
||||
if (typeof global.window === 'undefined') {
|
||||
global.window = {};
|
||||
}
|
||||
if (typeof global.window.indexedDB === 'undefined') {
|
||||
global.window.indexedDB = indexedDB;
|
||||
}
|
||||
|
||||
import { EventsCacheStore } from '../../js/nostr/managers/EventsCacheStore.js';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test, describe, beforeEach, afterEach } from 'node:test';
|
||||
|
||||
describe('EventsCacheStore', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new EventsCacheStore();
|
||||
|
||||
// Ensure clean state
|
||||
const db = await store.getDb();
|
||||
if (!db) {
|
||||
throw new Error("Failed to get DB instance (getDb returned null). indexedDB might not be polyfilled correctly.");
|
||||
}
|
||||
const tx = db.transaction(["events", "tombstones", "meta"], "readwrite");
|
||||
tx.objectStore("events").clear();
|
||||
tx.objectStore("tombstones").clear();
|
||||
tx.objectStore("meta").clear();
|
||||
await new Promise((resolve, reject) => {
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(new Error("Transaction aborted"));
|
||||
});
|
||||
});
|
||||
|
||||
test('persistSnapshot should store events and tombstones', async () => {
|
||||
const events = new Map([
|
||||
['1', { id: '1', title: 'Video 1' }],
|
||||
['2', { id: '2', title: 'Video 2' }]
|
||||
]);
|
||||
const tombstones = new Map([
|
||||
['key1', 1000]
|
||||
]);
|
||||
const savedAt = Date.now();
|
||||
|
||||
const result = await store.persistSnapshot({ events, tombstones, savedAt });
|
||||
|
||||
assert.equal(result.persisted, true);
|
||||
assert.equal(result.eventWrites, 2);
|
||||
assert.equal(result.tombstoneWrites, 1);
|
||||
|
||||
const snapshot = await store.restoreSnapshot();
|
||||
assert.deepEqual(snapshot.events, events);
|
||||
assert.deepEqual(snapshot.tombstones, tombstones);
|
||||
assert.equal(snapshot.savedAt, savedAt);
|
||||
});
|
||||
|
||||
test('persistSnapshot should only update changed items', async () => {
|
||||
const events = new Map([
|
||||
['1', { id: '1', title: 'Video 1' }]
|
||||
]);
|
||||
const tombstones = new Map();
|
||||
const savedAt = Date.now();
|
||||
|
||||
await store.persistSnapshot({ events, tombstones, savedAt });
|
||||
|
||||
// No changes
|
||||
const result1 = await store.persistSnapshot({ events, tombstones, savedAt });
|
||||
assert.equal(result1.eventWrites, 0);
|
||||
|
||||
// Update item
|
||||
events.set('1', { id: '1', title: 'Video 1 Updated' });
|
||||
const result2 = await store.persistSnapshot({ events, tombstones, savedAt });
|
||||
assert.equal(result2.eventWrites, 1);
|
||||
});
|
||||
|
||||
test('persistSnapshot should delete removed items', async () => {
|
||||
const events = new Map([
|
||||
['1', { id: '1', title: 'Video 1' }],
|
||||
['2', { id: '2', title: 'Video 2' }]
|
||||
]);
|
||||
const tombstones = new Map();
|
||||
const savedAt = Date.now();
|
||||
|
||||
await store.persistSnapshot({ events, tombstones, savedAt });
|
||||
|
||||
events.delete('2');
|
||||
const result = await store.persistSnapshot({ events, tombstones, savedAt });
|
||||
|
||||
assert.equal(result.eventDeletes, 1);
|
||||
|
||||
const snapshot = await store.restoreSnapshot();
|
||||
assert.equal(snapshot.events.size, 1);
|
||||
assert.equal(snapshot.events.has('1'), true);
|
||||
assert.equal(snapshot.events.has('2'), false);
|
||||
});
|
||||
|
||||
test('persistSnapshot should respect dirty keys optimization', async () => {
|
||||
const events = new Map([
|
||||
['1', { id: '1', title: 'Video 1' }],
|
||||
['2', { id: '2', title: 'Video 2' }]
|
||||
]);
|
||||
const tombstones = new Map();
|
||||
const savedAt = Date.now();
|
||||
|
||||
await store.persistSnapshot({ events, tombstones, savedAt });
|
||||
|
||||
events.set('1', { id: '1', title: 'Video 1 Updated' });
|
||||
|
||||
// Only mark '1' as dirty
|
||||
const dirtyEventIds = new Set(['1']);
|
||||
|
||||
// Even if we pass the whole map, it should optimized based on dirty keys if provided
|
||||
const result = await store.persistSnapshot({ events, tombstones, savedAt }, dirtyEventIds);
|
||||
|
||||
assert.equal(result.eventWrites, 1);
|
||||
// It should skip checking '2' because it's not in dirtyEventIds and exists in persisted fingerprints
|
||||
// Note: The implementation detail calculates skipped only if we iterate over non-dirty items,
|
||||
// but the optimization restricts iteration to dirty items only, so skipped remains 0.
|
||||
// assert.ok(result.skipped >= 1);
|
||||
|
||||
const snapshot = await store.restoreSnapshot();
|
||||
assert.deepEqual(snapshot.events.get('1'), { id: '1', title: 'Video 1 Updated' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user