[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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-17 14:49:40 -04:00
1 parent 11ff205fc0
commit 362016ead6
3 files changed
+150

No files matched your search

+7
View File
@@ -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);
}
+37
View File
@@ -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;
+106
View File
@@ -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);