mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
[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:
1 parent
5db18c9bd0
commit
11ff205fc0
2 files changed
+166
-2
No files matched your search
@@ -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 = {}) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user