[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

+109
View File
@@ -450,6 +450,115 @@ test("fetchWatchHistory routes decryption by ciphertext format and never tries t
}
});
test("fetchWatchHistory caps uncached chunk decryption to chunkDecryptLimit, newest chunk first", async () => {
// A deep watch history references many monthly chunks. Under a NIP-46 remote
// signer each uncached chunk decrypt is a serialized published RPC, so we cap
// how many we decrypt up front and spend that budget on the *newest* chunks.
// The older chunks are deferred (reported via deferredChunkCount) for an
// on-demand "load older" pass.
const actorHex = "7".repeat(64);
const newCipher = "bmV3LWNodW5rLWNpcGhlcnRleHQ"; // base64, no "?iv=" => nip44
const oldCipher = "b2xkLWNodW5rLWNpcGhlcnRleHQ";
const pointerEvent = {
id: "pointer-cap",
pubkey: actorHex,
created_at: 1_700_500_000,
content: JSON.stringify({
version: 2,
snapshot: "snap-cap",
items: [],
chunkIndex: 0,
totalChunks: 2,
}),
tags: [
["snapshot", "snap-cap"],
["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-new`],
["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-old`],
],
};
const newChunk = {
id: "chunk-new-id",
pubkey: actorHex,
created_at: 1_700_500_200, // newer
content: newCipher,
tags: [["d", "chunk-new"], ["encrypted", "nip44"]],
};
const oldChunk = {
id: "chunk-old-id",
pubkey: actorHex,
created_at: 1_700_400_000, // older
content: oldCipher,
tags: [["d", "chunk-old"], ["encrypted", "nip44"]],
};
const decryptedPayloads = [];
const signer = {
nip44Decrypt: async (pubkey, payload) => {
decryptedPayloads.push(payload);
if (payload === newCipher) {
return JSON.stringify({
version: 2,
items: [{ type: "e", value: "from-new-chunk", watchedAt: 200 }],
});
}
return JSON.stringify({
version: 2,
items: [{ type: "e", value: "from-old-chunk", watchedAt: 100 }],
});
},
};
let listCall = 0;
const pool = {
async list() {
listCall += 1;
if (listCall === 1) {
return [pointerEvent];
}
return [oldChunk, newChunk]; // relay returns out of recency order
},
};
const manager = createWatchHistoryManager({
getActivePubkey: () => actorHex,
resolveActiveSigner: () => signer,
shouldRequestExtensionPermissions: () => false,
getPool: () => pool,
getReadRelays: () => ["wss://relay.example"],
});
try {
const result = await fetchWatchHistory(manager, actorHex, {
forceRefresh: true,
chunkDecryptLimit: 1,
});
assert.equal(
decryptedPayloads.length,
1,
"only one chunk should be decrypted under chunkDecryptLimit: 1",
);
assert.equal(
decryptedPayloads[0],
newCipher,
"the newest chunk (highest created_at) must be the one decrypted",
);
assert.equal(
result.deferredChunkCount,
1,
"the older chunk should be reported as deferred",
);
assert.deepStrictEqual(
result.items.map((item) => item.value),
["from-new-chunk"],
"only the newest chunk's items should be present under the cap",
);
} finally {
manager.clear();
}
});
test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => {
const actorPubkey = "f".repeat(64);
const sessionPrivateKey = "a".repeat(64);