Merge pull request #260 from PR0M3TH3AN/codex/refactor-watch-history-batching-logic

Clarify watch history batching window
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-02 15:59:33 -04:00
commit 212f48c0e3
6 files changed
+100 -6

No files matched your search

+11
View File
@@ -106,6 +106,17 @@ export const WATCH_HISTORY_MAX_ITEMS = 1500;
*/
export const WATCH_HISTORY_BATCH_RESOLVE = true;
/**
* Maximum number of canonical items each batched watch-history response should
* include.
*
* Leave this as `null` (the default) to return the full
* `WATCH_HISTORY_MAX_ITEMS` window, even when batching is enabled. Deployments
* that page through histories in smaller slices can set a positive integer to
* cap the resolver output so pagination and API responses stay aligned.
*/
export const WATCH_HISTORY_BATCH_PAGE_SIZE = null;
/**
* Maximum size of the JSON payload for each watch-history chunk, measured
* before encryption.
+9
View File
@@ -27,6 +27,15 @@ chunkIndex, totalChunks, items[] }`. Items are pointer descriptors (`e` or `a`
references plus optional relay hints) so that relays never learn which titles
were played—only which events or addresses the client can dereference later.【F:js/nostr.js†L2337-L2364】
## Batching configuration
Operators can toggle grouped resolution with `WATCH_HISTORY_BATCH_RESOLVE` and
optionally cap each response by defining `WATCH_HISTORY_BATCH_PAGE_SIZE` in
`config/instance-config.js`. Leaving the page size unset returns the full
`WATCH_HISTORY_MAX_ITEMS` window while still letting the UI paginate locally,
but setting a positive integer keeps API payloads in lockstep with batched
renderers like `historyView`.【F:config/instance-config.js†L101-L123】【F:js/nostr.js†L2987-L2998】【F:js/historyView.js†L1-L38】
## Snapshot cadence
`WatchHistoryService.publishView` queues pointer entries while the feature flag
+2
View File
@@ -9,6 +9,7 @@ import {
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_BATCH_PAGE_SIZE,
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_FETCH_EVENT_LIMIT,
WATCH_HISTORY_CACHE_TTL_MS,
@@ -63,6 +64,7 @@ export { WATCH_HISTORY_LIST_IDENTIFIER };
export { WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS };
export { WATCH_HISTORY_MAX_ITEMS };
export { WATCH_HISTORY_BATCH_RESOLVE };
export { WATCH_HISTORY_BATCH_PAGE_SIZE };
export { WATCH_HISTORY_PAYLOAD_MAX_BYTES };
export { WATCH_HISTORY_FETCH_EVENT_LIMIT };
export { WATCH_HISTORY_CACHE_TTL_MS };
+12 -2
View File
@@ -7,7 +7,10 @@ import {
nostrClient,
updateWatchHistoryList,
} from "./nostr.js";
import { WATCH_HISTORY_BATCH_RESOLVE } from "./config.js";
import {
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_BATCH_PAGE_SIZE,
} from "./config.js";
export const WATCH_HISTORY_EMPTY_COPY =
"Your watch history is empty. Watch some videos to populate this list.";
@@ -19,7 +22,14 @@ const WATCH_HISTORY_METADATA_PREF_KEY =
"bitvid:watch-history:metadata-preference";
const WATCH_HISTORY_PRIVACY_DISMISSED_KEY =
"bitvid:watch-history:privacy-banner-dismissed";
const WATCH_HISTORY_BATCH_SIZE = 12;
const DEFAULT_WATCH_HISTORY_BATCH_SIZE = 12;
const WATCH_HISTORY_BATCH_SIZE = (() => {
const raw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE);
if (!Number.isFinite(raw) || raw <= 0) {
return DEFAULT_WATCH_HISTORY_BATCH_SIZE;
}
return Math.floor(raw);
})();
const FALLBACK_THUMBNAIL = "assets/svg/default-thumbnail.svg";
const FALLBACK_AVATAR = "assets/svg/default-profile.svg";
const isDevEnv =
+7 -3
View File
@@ -9,6 +9,7 @@ import {
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
WATCH_HISTORY_MAX_ITEMS,
WATCH_HISTORY_BATCH_RESOLVE,
WATCH_HISTORY_BATCH_PAGE_SIZE,
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
WATCH_HISTORY_FETCH_EVENT_LIMIT,
WATCH_HISTORY_CACHE_TTL_MS,
@@ -2986,9 +2987,12 @@ class NostrClient {
fallbackItems,
);
const batchLimitRaw = Number(WATCH_HISTORY_BATCH_RESOLVE);
const batchLimit = Number.isFinite(batchLimitRaw) && batchLimitRaw > 0
? Math.min(Math.floor(batchLimitRaw), WATCH_HISTORY_MAX_ITEMS)
const shouldBatch = Boolean(WATCH_HISTORY_BATCH_RESOLVE);
const batchPageSizeRaw = Number(WATCH_HISTORY_BATCH_PAGE_SIZE);
const hasCustomBatchSize =
Number.isFinite(batchPageSizeRaw) && batchPageSizeRaw > 0;
const batchLimit = shouldBatch && hasCustomBatchSize
? Math.min(Math.floor(batchPageSizeRaw), WATCH_HISTORY_MAX_ITEMS)
: WATCH_HISTORY_MAX_ITEMS;
const canonicalItems = canonicalizeWatchHistoryItems(
+59 -1
View File
@@ -3,7 +3,8 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
const { WATCH_HISTORY_PAYLOAD_MAX_BYTES } = await import("../js/config.js");
const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS } =
await import("../js/config.js");
const {
getWatchHistoryV2Enabled,
setWatchHistoryV2Enabled,
@@ -604,6 +605,62 @@ async function testPublishSnapshotFailureRetry() {
}
}
async function testResolveWatchHistoryBatchingWindow() {
const actor = "batch-window-actor";
const originalEnsure = nostrClient.ensureSessionActor;
const originalPub = nostrClient.pubkey;
const originalFetch = nostrClient.fetchWatchHistory;
const originalCache = nostrClient.watchHistoryCache;
const originalStorage = nostrClient.watchHistoryStorage;
try {
nostrClient.pubkey = "";
nostrClient.ensureSessionActor = async () => actor;
nostrClient.watchHistoryCache = new Map();
nostrClient.watchHistoryStorage = { version: 2, actors: {} };
const syntheticItems = Array.from({ length: 24 }, (_, index) => ({
type: "e",
value: `pointer-${index}`,
watchedAt: 1_700_100_000 + index,
}));
nostrClient.fetchWatchHistory = async () => ({
items: syntheticItems.map((item) => ({ ...item })),
snapshotId: "batch-snapshot",
pointerEvent: null,
});
const resolved = await nostrClient.resolveWatchHistory(actor, {
forceRefresh: true,
});
assert.equal(
resolved.length,
Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length),
"batched resolve should return the full window when no page size override is configured",
);
assert(resolved.length > 1, "resolved history should include multiple entries");
assert.equal(
resolved[0]?.value,
"pointer-23",
"resolved items should remain sorted by newest watchedAt value",
);
const uniqueValues = new Set(resolved.map((item) => item?.value));
assert.equal(
uniqueValues.size,
resolved.length,
"resolved history should retain all unique pointers",
);
} finally {
nostrClient.ensureSessionActor = originalEnsure;
nostrClient.pubkey = originalPub;
nostrClient.fetchWatchHistory = originalFetch;
nostrClient.watchHistoryCache = originalCache;
nostrClient.watchHistoryStorage = originalStorage;
}
}
async function testWatchHistoryServiceIntegration() {
poolHarness.reset();
poolHarness.setResolver(() => ({ ok: true }));
@@ -719,6 +776,7 @@ async function testWatchHistoryServiceIntegration() {
await testPublishSnapshotCanonicalizationAndChunking();
await testPublishSnapshotUsesExtensionCrypto();
await testPublishSnapshotFailureRetry();
await testResolveWatchHistoryBatchingWindow();
await testWatchHistoryServiceIntegration();
console.log("watch-history.test.mjs completed successfully");