From 362016ead603f07af26d1d27a5003b5a0a06c453 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:49:40 -0400 Subject: [PATCH] [watch-history] thread chunkDecryptLimit through resolve + expose deferred count Service-layer plumbing for bounded watch-history loading (slice B part 2). manager.resolve() now forwards chunkDecryptLimit to fetch() and records the resulting deferred-chunk count in a per-actor side-channel, so resolve() keeps returning a bare items array (its sole caller depends on that) while the "more older chunks available" signal is still retrievable. Exposed via getWatchHistoryDeferredChunkCount() on the manager and the NostrClient facade. clear() resets the side-channel. Still inert by default (no caller passes a finite limit yet); this is the substrate for activating fast-first-render. Tested: resolve under chunkDecryptLimit:1 returns only the newest chunk's items and reports deferredChunkCount=1, reset to 0 after clear(). Co-Authored-By: Claude Opus 4.8 --- js/nostr/client.js | 7 ++ js/nostr/watchHistory.js | 37 +++++++++++ tests/nostr/watchHistory.test.js | 106 +++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/js/nostr/client.js b/js/nostr/client.js index 7e9d1f2b..f6ef3176 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -61,6 +61,7 @@ import { removeWatchHistoryItem as removeWatchHistoryItemWithManager, fetchWatchHistory as fetchWatchHistoryWithManager, resolveWatchHistory as resolveWatchHistoryWithManager, + getWatchHistoryDeferredChunkCount as getWatchHistoryDeferredChunkCountFromManager, } from "./watchHistory.js"; import { buildVideoPostEvent, @@ -978,6 +979,12 @@ export class NostrClient { options, ); } + getWatchHistoryDeferredChunkCount(actorInput) { + return getWatchHistoryDeferredChunkCountFromManager( + this.watchHistory, + actorInput, + ); + } async listVideoViewEvents(pointer, options = {}) { return listVideoViewEventsForClient(this, pointer, options); } diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 66767194..a1a177cd 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -962,6 +962,10 @@ class WatchHistoryManager { this.cacheTtlMs = 0; this.fingerprints = new Map(); this.lastCreatedAt = 0; + // Per-actor count of older chunks left undecrypted by the last bounded + // fetch (see fetch()'s chunkDecryptLimit). Read by the service to decide + // whether to offer "load older". + this.deferredChunkCounts = new Map(); profileCache.subscribe((event, detail) => { if (event === "profileChanged") { @@ -2314,7 +2318,18 @@ class WatchHistoryManager { const fetchResult = await this.fetch(resolvedActor, { forceRefresh: options.forceRefresh || false, + ...(options.chunkDecryptLimit !== undefined + ? { chunkDecryptLimit: options.chunkDecryptLimit } + : {}), }); + // Surface how many older chunks were left undecrypted so the service can + // decide whether to offer "load older". Stored per-actor side-channel so + // resolve() can keep returning a plain items array (its sole caller relies + // on that contract). + this.setDeferredChunkCount( + actorKey, + Number(fetchResult?.deferredChunkCount) || 0, + ); const merged = mergeWatchHistoryItemsWithFallback( { version: 2, @@ -2386,9 +2401,27 @@ class WatchHistoryManager { this.cache.clear(); this.fingerprints.clear(); this.refreshPromises.clear(); + this.deferredChunkCounts.clear(); this.lastCreatedAt = 0; this.storage = null; } + + setDeferredChunkCount(actorInput, count) { + const actorKey = normalizeActorKey(actorInput); + if (!actorKey) { + return; + } + const normalized = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; + this.deferredChunkCounts.set(actorKey, normalized); + } + + getDeferredChunkCount(actorInput) { + const actorKey = normalizeActorKey(actorInput); + if (!actorKey) { + return 0; + } + return this.deferredChunkCounts.get(actorKey) || 0; + } } export function getWatchHistoryCacheTtlMs(manager) { @@ -2448,6 +2481,10 @@ export function resolveWatchHistory(manager, actorInput, options = {}) { return manager.resolve(actorInput, options); } +export function getWatchHistoryDeferredChunkCount(manager, actorInput) { + return manager.getDeferredChunkCount(actorInput); +} + /** * Create a watch-history manager. Callers supply the nostr client hooks needed * to sign, encrypt, fetch, and publish events. All dependencies are optional; diff --git a/tests/nostr/watchHistory.test.js b/tests/nostr/watchHistory.test.js index 9ffc5ec3..7bca0ce1 100644 --- a/tests/nostr/watchHistory.test.js +++ b/tests/nostr/watchHistory.test.js @@ -7,7 +7,9 @@ import { createWatchHistoryManager, fetchWatchHistory, getWatchHistoryCacheTtlMs, + getWatchHistoryDeferredChunkCount, getWatchHistoryStorage, + resolveWatchHistory, } from "../../js/nostr/watchHistory.js"; import { WATCH_HISTORY_KIND } from "../../js/config.js"; import { NOTE_TYPES } from "../../js/nostrEventSchemas.js"; @@ -559,6 +561,110 @@ test("fetchWatchHistory caps uncached chunk decryption to chunkDecryptLimit, new } }); +test("resolveWatchHistory threads chunkDecryptLimit and records the deferred chunk count", async () => { + // resolve() returns a bare items array (its sole caller depends on that), so + // the "more older chunks available" signal is exposed via a per-actor + // side-channel the service reads to decide whether to offer "load older". + const actorHex = "8".repeat(64); + const newCipher = "cmVzb2x2ZS1uZXctY2lwaGVy"; // base64, no "?iv=" => nip44 + const oldCipher = "cmVzb2x2ZS1vbGQtY2lwaGVy"; + + const pointerEvent = { + id: "pointer-resolve-cap", + pubkey: actorHex, + created_at: 1_700_600_000, + content: JSON.stringify({ + version: 2, + snapshot: "snap-resolve", + items: [], + chunkIndex: 0, + totalChunks: 2, + }), + tags: [ + ["snapshot", "snap-resolve"], + ["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-r-new`], + ["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-r-old`], + ], + }; + const newChunk = { + id: "chunk-r-new-id", + pubkey: actorHex, + created_at: 1_700_600_200, + content: newCipher, + tags: [["d", "chunk-r-new"], ["encrypted", "nip44"]], + }; + const oldChunk = { + id: "chunk-r-old-id", + pubkey: actorHex, + created_at: 1_700_500_000, + content: oldCipher, + tags: [["d", "chunk-r-old"], ["encrypted", "nip44"]], + }; + + const signer = { + nip44Decrypt: async (_pubkey, payload) => + JSON.stringify({ + version: 2, + items: [ + { + type: "e", + value: payload === newCipher ? "r-new" : "r-old", + watchedAt: payload === newCipher ? 200 : 100, + }, + ], + }), + }; + + let listCall = 0; + const pool = { + async list() { + listCall += 1; + if (listCall === 1) { + return [pointerEvent]; + } + return [oldChunk, newChunk]; + }, + }; + + const manager = createWatchHistoryManager({ + getActivePubkey: () => actorHex, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + getPool: () => pool, + getReadRelays: () => ["wss://relay.example"], + }); + + try { + assert.equal( + getWatchHistoryDeferredChunkCount(manager, actorHex), + 0, + "no deferred chunks before any fetch", + ); + const items = await resolveWatchHistory(manager, actorHex, { + forceRefresh: true, + chunkDecryptLimit: 1, + }); + assert.ok(Array.isArray(items), "resolve must still return a bare items array"); + assert.deepStrictEqual( + items.map((item) => item.value), + ["r-new"], + "only the newest chunk's items should resolve under the cap", + ); + assert.equal( + getWatchHistoryDeferredChunkCount(manager, actorHex), + 1, + "the deferred side-channel should report the older chunk", + ); + } finally { + manager.clear(); + assert.equal( + getWatchHistoryDeferredChunkCount(manager, actorHex), + 0, + "clear() must reset the deferred chunk side-channel", + ); + } +}); + test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => { const actorPubkey = "f".repeat(64); const sessionPrivateKey = "a".repeat(64);