[watch-history] add newest-first, budget-capped chunk decryption (foundation)

Foundation for bounded watch-history loading (slice B part 2). A deep
history references many monthly chunks; under a NIP-46 remote signer each
uncached chunk decrypt is a serialized published relay RPC, so decrypting
them all up front blocks the first render and risks the relay rate limit.

manager.fetch now:
- sorts chunk events newest-first by created_at, so the decrypt budget is
  spent on the most recent history;
- accepts a chunkDecryptLimit option that caps how many *uncached*,
  genuinely-encrypted chunks are decrypted per fetch. Cached/plaintext
  chunks are always decrypted (they cost no RPC) and never count against
  the limit. Default is Infinity -> no behavior change for existing
  callers;
- returns deferredChunkCount so a caller can tell more history is
  available on demand (re-fetching with a larger limit reuses the chunk
  cache, so only the newly requested older chunks cost RPCs).

Test: two uncached nip44 chunks (newest + older) with chunkDecryptLimit:1
asserts only the newest is decrypted (1 RPC), deferredChunkCount is 1, and
only the newest chunk's items appear. Mutation-verified against both
removing the cap and removing the newest-first sort.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-17 14:42:56 -04:00
1 parent 5db18c9bd0
commit 11ff205fc0
2 files changed
+166 -2

No files matched your search

+57 -2
View File
@@ -1838,6 +1838,19 @@ class WatchHistoryManager {
}
async fetch(actorInput, options = {}) {
// Bound how many *uncached* watch-history chunks we decrypt up front. Each
// uncached chunk decrypt is a signer round-trip — and under a NIP-46 remote
// signer it is a published relay RPC routed through the serial 250ms queue,
// so a deep history (many monthly chunks) blocks the first render and risks
// the relay rate limit. Default is unlimited (no behavior change); callers
// opt into a finite limit and decrypt older chunks newest-first on demand.
// Already-cached and plaintext chunks are always decrypted (they cost no
// RPC) and never count against the limit.
const chunkDecryptLimitRaw = Number(options?.chunkDecryptLimit);
const chunkDecryptLimit =
Number.isFinite(chunkDecryptLimitRaw) && chunkDecryptLimitRaw >= 0
? Math.floor(chunkDecryptLimitRaw)
: Infinity;
const actorCandidates = [actorInput];
if (typeof this.deps.getActivePubkey === "function") {
actorCandidates.push(this.deps.getActivePubkey());
@@ -2069,6 +2082,7 @@ class WatchHistoryManager {
const toolkitCacheRef = { current: null };
let decryptedItems = [];
let deferredChunkCount = 0;
if (chunkIdentifiers.length && canAttemptDecrypt) {
try {
const results = await listEvents(readRelays, [
@@ -2078,9 +2092,40 @@ class WatchHistoryManager {
"#d": chunkIdentifiers,
},
]);
const chunkEvents = Array.isArray(results)
const allChunkEvents = Array.isArray(results)
? results.flat().filter((event) => event && typeof event === "object")
: [];
// Decrypt newest chunks first so the most recent history is the part we
// spend the (bounded) decrypt budget on. A chunk is "free" when its
// content is already plaintext or its plaintext is cached — those never
// count against the limit. Only uncached, genuinely-encrypted chunks
// consume the budget; any beyond it are deferred (the caller can request
// older chunks later, which re-uses the chunk cache for the rest).
const sortedChunkEvents = [...allChunkEvents].sort(
(a, b) => (Number(b?.created_at) || 0) - (Number(a?.created_at) || 0),
);
const chunkEvents = [];
let uncachedBudget = chunkDecryptLimit;
for (const event of sortedChunkEvents) {
const ciphertext =
typeof event.content === "string" ? event.content : "";
const isFree =
!ciphertext ||
looksLikeJsonStructure(ciphertext) ||
Boolean(getCachedChunkPlaintext(event.id));
if (isFree) {
chunkEvents.push(event);
continue;
}
if (uncachedBudget > 0) {
uncachedBudget -= 1;
chunkEvents.push(event);
} else {
deferredChunkCount += 1;
}
}
const chunkResults = await pMap(
chunkEvents,
async (event) => {
@@ -2221,7 +2266,17 @@ class WatchHistoryManager {
};
this.cache.set(actorKey, entry);
this.persistEntry(actorKey, entry);
return { pointerEvent: latestEvent, items: flatItems, snapshotId: "" };
return {
pointerEvent: latestEvent,
items: flatItems,
snapshotId: "",
// Number of older encrypted chunks left undecrypted because the caller
// capped the per-fetch decrypt budget. >0 means "more history available
// on demand"; the caller can re-fetch with a larger chunkDecryptLimit
// (already-decrypted chunks are served from cache, so only the newly
// requested older chunks cost RPCs).
deferredChunkCount,
};
}
async resolve(actorInput, options = {}) {