diff --git a/config/instance-config.js b/config/instance-config.js index 2ef81c37..2586c026 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -83,3 +83,23 @@ export const WATCH_HISTORY_MAX_ITEMS = 1500; * bursty loads can disable batching at the cost of additional round trips. */ export const WATCH_HISTORY_BATCH_RESOLVE = true; + +/** + * Maximum size of the JSON payload for each watch-history chunk, measured + * before encryption. + * + * BitVid splits large histories across multiple events to stay within relay + * limits. This cap keeps individual chunks under roughly 60 KB so that + * encrypted payloads remain relay-friendly even after base64 expansion. + * Lower the number if your relays enforce tighter limits. + */ +export const WATCH_HISTORY_PAYLOAD_MAX_BYTES = 60000; + +/** + * Number of watch-history events to request when syncing from relays. + * + * Chunked histories can span several events. This fetch limit should exceed + * the maximum number of chunks you expect a client to publish in one snapshot + * so the UI can stitch the full list back together. + */ +export const WATCH_HISTORY_FETCH_EVENT_LIMIT = 12; diff --git a/js/config.js b/js/config.js index fe95d495..460f5adb 100644 --- a/js/config.js +++ b/js/config.js @@ -8,6 +8,8 @@ import { WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_MAX_ITEMS, WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_PAYLOAD_MAX_BYTES, + WATCH_HISTORY_FETCH_EVENT_LIMIT, } from "../config/instance-config.js"; export const isDevMode = true; // Set to false for production @@ -55,3 +57,5 @@ export { WATCH_HISTORY_KIND }; export { WATCH_HISTORY_LIST_IDENTIFIER }; export { WATCH_HISTORY_MAX_ITEMS }; export { WATCH_HISTORY_BATCH_RESOLVE }; +export { WATCH_HISTORY_PAYLOAD_MAX_BYTES }; +export { WATCH_HISTORY_FETCH_EVENT_LIMIT }; diff --git a/js/nostr.js b/js/nostr.js index 197d4e97..08647f28 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -6,6 +6,8 @@ import { WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_MAX_ITEMS, WATCH_HISTORY_BATCH_RESOLVE, + WATCH_HISTORY_PAYLOAD_MAX_BYTES, + WATCH_HISTORY_FETCH_EVENT_LIMIT, } from "./config.js"; import { ACCEPT_LEGACY_V1 } from "./constants.js"; import { accessControl } from "./accessControl.js"; @@ -239,26 +241,163 @@ function normalizePointersFromPayload(payload) { function parseWatchHistoryPayload(plaintext) { if (typeof plaintext !== "string") { - return { version: 0, items: [] }; + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; } try { const parsed = JSON.parse(plaintext); if (!parsed || typeof parsed !== "object") { - return { version: 0, items: [] }; + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; } const version = Number.isFinite(parsed.version) ? parsed.version : 0; const items = normalizePointersFromPayload(parsed); - return { version, items }; + const snapshot = + typeof parsed.snapshot === "string" ? parsed.snapshot : ""; + const chunkIndex = Number.isFinite(parsed.chunkIndex) + ? Math.max(0, Math.floor(parsed.chunkIndex)) + : 0; + const totalChunks = Number.isFinite(parsed.totalChunks) + ? Math.max(1, Math.floor(parsed.totalChunks)) + : 1; + return { version, items, snapshot, chunkIndex, totalChunks }; } catch (error) { if (isDevMode) { console.warn("[nostr] Failed to parse watch history payload:", error); } - return { version: 0, items: [] }; + return { + version: 0, + items: [], + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; } } +function chunkWatchHistoryPayloadItems(payloadItems, snapshotId, maxBytes) { + const items = Array.isArray(payloadItems) ? payloadItems : []; + const safeMax = Math.max(128, Math.floor(maxBytes || 0)); + const measurementLimit = Math.max(64, safeMax - 32); + const normalizedSnapshot = + typeof snapshotId === "string" ? snapshotId : ""; + + const chunks = []; + const skipped = []; + let current = []; + + const estimateLength = (chunkItems, chunkIndex, totalGuess) => + JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex, + totalChunks: totalGuess, + items: chunkItems, + }).length; + + for (const item of items) { + if (!item || typeof item !== "object") { + continue; + } + + if (!current.length) { + const candidate = [item]; + const size = estimateLength(candidate, chunks.length, chunks.length + 1); + if (size <= measurementLimit) { + current = candidate; + } else { + skipped.push(item); + } + continue; + } + + const chunkIndex = chunks.length; + const candidate = [...current, item]; + const size = estimateLength(candidate, chunkIndex, chunkIndex + 1); + if (size <= measurementLimit) { + current = candidate; + continue; + } + + chunks.push(current); + current = []; + + const nextIndex = chunks.length; + const soloSize = estimateLength([item], nextIndex, nextIndex + 1); + if (soloSize <= measurementLimit) { + current = [item]; + } else { + skipped.push(item); + } + } + + if (current.length || chunks.length === 0) { + chunks.push(current); + } + + let needsRebalance = true; + while (needsRebalance) { + needsRebalance = false; + for (let index = 0; index < chunks.length; index++) { + let chunkItems = chunks[index]; + if (!Array.isArray(chunkItems)) { + chunks[index] = []; + chunkItems = chunks[index]; + } + + let payloadSize = JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex: index, + totalChunks: chunks.length, + items: chunkItems, + }).length; + + while (chunkItems.length && payloadSize > safeMax) { + const overflow = chunkItems.pop(); + if (!overflow) { + break; + } + if (!chunks[index + 1]) { + chunks[index + 1] = []; + } + chunks[index + 1].unshift(overflow); + needsRebalance = true; + chunkItems = chunks[index]; + payloadSize = JSON.stringify({ + version: 2, + snapshot: normalizedSnapshot, + chunkIndex: index, + totalChunks: chunks.length, + items: chunkItems, + }).length; + } + } + } + + while (chunks.length > 1 && chunks[chunks.length - 1].length === 0) { + chunks.pop(); + } + + if (!chunks.length) { + chunks.push([]); + } + + return { chunks, skipped }; +} + function eventToAddressPointer(event) { if (!event || typeof event !== "object") { return ""; @@ -1026,13 +1165,28 @@ class NostrClient { ? normalizedPayload.items : []; + const version = Number.isFinite(normalizedPayload.version) + ? normalizedPayload.version + : 1; + const prepared = { - version: Number.isFinite(normalizedPayload.version) - ? normalizedPayload.version - : 1, + version, items, }; + if (version >= 2) { + prepared.snapshot = + typeof normalizedPayload.snapshot === "string" + ? normalizedPayload.snapshot + : ""; + prepared.chunkIndex = Number.isFinite(normalizedPayload.chunkIndex) + ? Math.max(0, Math.floor(normalizedPayload.chunkIndex)) + : 0; + prepared.totalChunks = Number.isFinite(normalizedPayload.totalChunks) + ? Math.max(1, Math.floor(normalizedPayload.totalChunks)) + : 1; + } + const plaintext = JSON.stringify(prepared); let ciphertext = ""; @@ -1101,7 +1255,13 @@ class NostrClient { async decryptWatchHistoryEvent(pointerEvent, actorPubkey) { const fallbackItems = extractPointerItemsFromEvent(pointerEvent); - const fallbackPayload = { version: 0, items: fallbackItems }; + const fallbackPayload = { + version: 0, + items: fallbackItems, + snapshot: "", + chunkIndex: 0, + totalChunks: 1, + }; const normalizedActor = typeof actorPubkey === "string" && actorPubkey.trim() @@ -1295,61 +1455,49 @@ class NostrClient { relay: item.relay || null, })); - const encryptionResult = await this.encryptWatchHistoryPayload( - normalizedActor, - { version: 1, items: payloadItems } - ); - if (!encryptionResult.ok) { - if (isDevMode) { - console.warn( - "[nostr] Unable to encrypt watch history snapshot:", - encryptionResult.error - ); - } - return encryptionResult; - } + const maxBytesCandidate = Number.isFinite(this.watchHistoryPayloadMaxBytes) + ? this.watchHistoryPayloadMaxBytes + : WATCH_HISTORY_PAYLOAD_MAX_BYTES; + const maxPayloadBytes = Math.max(64, Math.floor(maxBytesCandidate)); + const snapshotId = `${Math.floor(Date.now() / 1000)}-${Math.random() + .toString(36) + .slice(2, 10)}`; - const pointerTags = trimmedItems.map((item) => { - const tag = [item.type, item.value]; - if (item.relay) { - tag.push(item.relay); - } - return tag; + const chunkInfo = chunkWatchHistoryPayloadItems( + payloadItems, + snapshotId, + maxPayloadBytes + ); + const chunkItemsList = chunkInfo.chunks || []; + const skippedKeys = new Set( + (chunkInfo.skipped || []) + .map((item) => pointerKey(item)) + .filter((key) => !!key) + ); + + const persistedItems = trimmedItems.filter((item) => { + const key = pointerKey(item); + return key && !skippedKeys.has(key); }); - const tags = [ - ["d", WATCH_HISTORY_LIST_IDENTIFIER], - ["encrypted", "nip04"], - ...pointerTags, - ]; + if (chunkInfo.skipped?.length && isDevMode) { + console.warn( + `[nostr] Skipped ${chunkInfo.skipped.length} oversized watch history entries while chunking.` + ); + } - const event = { - kind: WATCH_HISTORY_KIND, - pubkey: normalizedActor, - created_at: Math.floor(Date.now() / 1000), - tags, - content: encryptionResult.ciphertext, - }; + const relays = + Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; - let signedEvent; const normalizedLogged = typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; - - if ( - normalizedActor.toLowerCase() === normalizedLogged && + const normalizedActorLower = normalizedActor.toLowerCase(); + const useExtension = + normalizedActorLower === normalizedLogged && window?.nostr && - typeof window.nostr.signEvent === "function" - ) { - try { - signedEvent = await window.nostr.signEvent(event); - } catch (error) { - console.warn( - "[nostr] Failed to sign watch history list with extension:", - error - ); - return { ok: false, error: "signing-failed", details: error }; - } - } else { + typeof window.nostr.signEvent === "function"; + + if (!useExtension) { try { if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { await this.ensureSessionActor(true); @@ -1357,46 +1505,134 @@ class NostrClient { if (!this.sessionActor || this.sessionActor.pubkey !== normalizedActor) { throw new Error("session-actor-mismatch"); } - signedEvent = signEventWithPrivateKey( - event, - this.sessionActor.privateKey - ); } catch (error) { console.warn( - "[nostr] Failed to sign watch history list with session key:", + "[nostr] Failed to prepare signing key for watch history chunks:", error ); return { ok: false, error: "signing-failed", details: error }; } } - const relays = - Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + const totalChunks = chunkItemsList.length || 1; + const baseTimestamp = Math.floor(Date.now() / 1000); + const chunkResults = []; + let overallSuccess = true; - const publishResults = await Promise.all( - relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) - ); + for (let index = 0; index < totalChunks; index++) { + const chunkItems = Array.isArray(chunkItemsList[index]) + ? chunkItemsList[index] + : []; - const success = publishResults.some((result) => result.success); - if (!success) { - console.warn( - "[nostr] Failed to publish watch history list:", - publishResults + const payload = { + version: 2, + snapshot: snapshotId, + chunkIndex: index, + totalChunks, + items: chunkItems, + }; + + const encryptionResult = await this.encryptWatchHistoryPayload( + normalizedActor, + payload ); + if (!encryptionResult.ok) { + if (isDevMode) { + console.warn( + "[nostr] Unable to encrypt watch history chunk:", + encryptionResult.error + ); + } + return encryptionResult; + } + + const pointerTags = chunkItems.map((item) => { + const tag = [item.type, item.value]; + if (item.relay) { + tag.push(item.relay); + } + return tag; + }); + + const tags = [ + ["d", WATCH_HISTORY_LIST_IDENTIFIER], + ["encrypted", "nip04"], + ["snapshot", snapshotId], + ["chunk", String(index), String(totalChunks)], + ...pointerTags, + ]; + if (index === 0) { + tags.splice(2, 0, ["head", "1"]); + } + + const event = { + kind: WATCH_HISTORY_KIND, + pubkey: normalizedActor, + created_at: baseTimestamp + (totalChunks - index - 1), + tags, + content: encryptionResult.ciphertext, + }; + + let signedEvent; + if (useExtension) { + try { + signedEvent = await window.nostr.signEvent(event); + } catch (error) { + console.warn( + "[nostr] Failed to sign watch history chunk with extension:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } else { + try { + signedEvent = signEventWithPrivateKey( + event, + this.sessionActor.privateKey + ); + } catch (error) { + console.warn( + "[nostr] Failed to sign watch history chunk with session key:", + error + ); + return { ok: false, error: "signing-failed", details: error }; + } + } + + const publishResults = await Promise.all( + relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) + ); + + const chunkSuccess = publishResults.some((result) => result.success); + if (!chunkSuccess) { + overallSuccess = false; + console.warn( + "[nostr] Failed to publish watch history chunk:", + publishResults + ); + } + + chunkResults.push({ + chunkIndex: index, + event: signedEvent, + results: publishResults, + success: chunkSuccess, + }); } - if (success) { + if (overallSuccess) { this.cancelWatchHistoryRepublish(normalizedActor); - } else if (allowRetry) { - this.scheduleWatchHistoryRepublish(normalizedActor, trimmedItems); + } else if (allowRetry && persistedItems.length) { + this.scheduleWatchHistoryRepublish(normalizedActor, persistedItems); } const baselineEntry = existingEntry || this.watchHistoryCache.get(normalizedActor) || null; + const headEvent = chunkResults[0]?.event || null; const newEntry = this.createWatchHistoryEntry( - signedEvent, - trimmedItems, + headEvent, + persistedItems, Date.now(), baselineEntry ); @@ -1405,8 +1641,12 @@ class NostrClient { this.persistWatchHistoryEntry(normalizedActor, newEntry); return { - ok: success, - event: signedEvent, + ok: overallSuccess, + event: headEvent, + events: chunkResults.map((chunk) => chunk.event), + chunks: chunkResults, + results: chunkResults.map((chunk) => chunk.results), + snapshot: snapshotId, items: newEntry.items .map((item) => clonePointerItem(item)) .filter(Boolean), @@ -1750,44 +1990,41 @@ class NostrClient { const relayList = Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + const fetchLimitCandidate = Number.isFinite(this.watchHistoryFetchEventLimit) + ? this.watchHistoryFetchEventLimit + : WATCH_HISTORY_FETCH_EVENT_LIMIT; + const fetchLimit = Math.max(1, Math.floor(fetchLimitCandidate)); + const filter = { kinds: [WATCH_HISTORY_KIND], authors: [actor], "#d": [WATCH_HISTORY_LIST_IDENTIFIER], - limit: 1, + limit: fetchLimit, }; - let pointerEvent = null; + let fetchedEvents = []; try { - const perRelay = await Promise.all( - relayList.map(async (url) => { - try { - const evt = await this.pool.get([url], filter); - return evt || null; - } catch (err) { - if (isDevMode) { - console.warn( - `[nostr] Failed to fetch watch history from ${url}:`, - err - ); - } - return null; + const events = await this.pool.list(relayList, [filter]); + if (Array.isArray(events)) { + const dedupe = new Map(); + for (const evt of events) { + if (!evt || typeof evt !== "object") { + continue; } - }) - ); - - const events = perRelay.filter(Boolean); - if (events.length) { - pointerEvent = events.reduce((latest, candidate) => { - if (!latest) { - return candidate; + const id = typeof evt.id === "string" ? evt.id : null; + if (!id) { + continue; } - const latestCreated = - typeof latest.created_at === "number" ? latest.created_at : 0; - const candidateCreated = - typeof candidate.created_at === "number" ? candidate.created_at : 0; - return candidateCreated > latestCreated ? candidate : latest; - }, null); + const existing = dedupe.get(id); + if ( + !existing || + (typeof evt.created_at === "number" ? evt.created_at : 0) > + (typeof existing.created_at === "number" ? existing.created_at : 0) + ) { + dedupe.set(id, evt); + } + } + fetchedEvents = Array.from(dedupe.values()); } } catch (error) { if (isDevMode) { @@ -1795,32 +2032,187 @@ class NostrClient { } } + fetchedEvents.sort((a, b) => { + const aCreated = typeof a?.created_at === "number" ? a.created_at : 0; + const bCreated = typeof b?.created_at === "number" ? b.created_at : 0; + return bCreated - aCreated; + }); + + const snapshotMap = new Map(); + + for (const event of fetchedEvents) { + if (!event || typeof event !== "object") { + continue; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + let taggedSnapshotId = ""; + let taggedChunkIndex = null; + let taggedTotalChunks = null; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + if (tag[0] === "snapshot" && typeof tag[1] === "string") { + taggedSnapshotId = tag[1]; + } else if (tag[0] === "chunk") { + const index = Number.parseInt(tag[1], 10); + const total = Number.parseInt(tag[2], 10); + if (Number.isFinite(index)) { + taggedChunkIndex = Math.max(0, index); + } + if (Number.isFinite(total) && total > 0) { + taggedTotalChunks = total; + } + } + } + + const payload = await this.decryptWatchHistoryEvent(event, actor); + let snapshotId = + (typeof payload.snapshot === "string" && payload.snapshot) || + taggedSnapshotId; + if (!snapshotId) { + const createdAt = + typeof event.created_at === "number" ? event.created_at : 0; + const fallbackId = + typeof event.id === "string" && event.id + ? event.id + : `${createdAt}:${Math.random().toString(36).slice(2, 10)}`; + snapshotId = fallbackId; + } + + let chunkIndex = Number.isFinite(payload.chunkIndex) + ? Math.max(0, Math.floor(payload.chunkIndex)) + : 0; + let totalChunks = Number.isFinite(payload.totalChunks) + ? Math.max(1, Math.floor(payload.totalChunks)) + : 1; + + if (Number.isFinite(taggedChunkIndex)) { + chunkIndex = Math.max(0, Math.floor(taggedChunkIndex)); + } + if (Number.isFinite(taggedTotalChunks)) { + totalChunks = Math.max(1, Math.floor(taggedTotalChunks)); + } + + const bucket = snapshotMap.get(snapshotId) || { + snapshotId, + chunks: new Map(), + expectedChunks: totalChunks, + latestCreatedAt: + typeof event.created_at === "number" ? event.created_at : 0, + version: payload.version || 0, + }; + + bucket.chunks.set(chunkIndex, { + event, + payload: { + ...payload, + snapshot: snapshotId, + chunkIndex, + totalChunks, + }, + created_at: typeof event.created_at === "number" ? event.created_at : 0, + }); + bucket.expectedChunks = Math.max(bucket.expectedChunks, totalChunks); + const createdAt = + typeof event.created_at === "number" ? event.created_at : 0; + if (createdAt > bucket.latestCreatedAt) { + bucket.latestCreatedAt = createdAt; + } + bucket.version = Math.max(bucket.version, payload.version || 0); + snapshotMap.set(snapshotId, bucket); + } + + const snapshotBuckets = Array.from(snapshotMap.values()).sort( + (a, b) => b.latestCreatedAt - a.latestCreatedAt + ); + + let selectedSnapshot = null; + let fallbackSnapshot = null; + + for (const bucket of snapshotBuckets) { + const chunkIndices = Array.from(bucket.chunks.keys()).sort( + (a, b) => a - b + ); + const seenKeys = new Set(); + const combinedItems = []; + let pointerCandidate = null; + + for (const index of chunkIndices) { + const chunk = bucket.chunks.get(index); + if (!chunk) { + continue; + } + if (!pointerCandidate && chunk.payload.chunkIndex === 0) { + pointerCandidate = chunk.event; + } + const items = Array.isArray(chunk.payload.items) + ? chunk.payload.items + : []; + for (const pointer of items) { + const key = pointerKey(pointer); + if (!key || seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + combinedItems.push(pointer); + } + } + + if (!pointerCandidate && chunkIndices.length) { + const firstChunk = bucket.chunks.get(chunkIndices[0]); + pointerCandidate = firstChunk?.event || null; + } + + const snapshot = { + snapshotId: bucket.snapshotId, + items: combinedItems, + pointerEvent: pointerCandidate || null, + version: bucket.version, + chunkCount: chunkIndices.length, + expectedChunks: Math.max(bucket.expectedChunks, chunkIndices.length || 1), + }; + + if (!fallbackSnapshot) { + fallbackSnapshot = snapshot; + } + + if (snapshot.chunkCount >= snapshot.expectedChunks) { + selectedSnapshot = snapshot; + break; + } + + if (!selectedSnapshot) { + selectedSnapshot = snapshot; + } + } + + const effectiveSnapshot = selectedSnapshot || fallbackSnapshot || null; + const previousEntry = this.watchHistoryCache.get(actor) || null; - const decryptedPayload = pointerEvent - ? await this.decryptWatchHistoryEvent(pointerEvent, actor) - : { version: 0, items: [] }; - const remoteItems = Array.isArray(decryptedPayload.items) - ? decryptedPayload.items - : []; - - let items = remoteItems; - let pointerForEntry = pointerEvent; - let usedFallback = false; - const storedItems = Array.isArray(stored?.items) ? stored.items : []; const storedPointerEvent = stored?.pointerEvent || null; - if (!pointerEvent && previousEntry?.pointerEvent) { - pointerForEntry = previousEntry.pointerEvent; - } else if (!pointerEvent && storedPointerEvent) { - pointerForEntry = storedPointerEvent; + const remoteItems = Array.isArray(effectiveSnapshot?.items) + ? effectiveSnapshot.items + : []; + + let pointerForEntry = effectiveSnapshot?.pointerEvent || null; + if (!pointerForEntry) { + if (previousEntry?.pointerEvent) { + pointerForEntry = previousEntry.pointerEvent; + } else if (storedPointerEvent) { + pointerForEntry = storedPointerEvent; + } } - const shouldFallback = - (!pointerEvent && !items.length) || - (pointerEvent && decryptedPayload.version === 0 && !items.length); + let items = remoteItems; + let usedFallback = false; + const remoteVersion = effectiveSnapshot?.version ?? 0; - if (shouldFallback) { + if (!effectiveSnapshot || (remoteVersion === 0 && !remoteItems.length)) { if (previousEntry?.items?.length) { items = previousEntry.items; usedFallback = true; @@ -1840,7 +2232,11 @@ class NostrClient { this.watchHistoryCache.set(actor, entry); this.persistWatchHistoryEntry(actor, entry); - if (!pointerEvent && usedFallback && entry.items.length) { + if ( + (!effectiveSnapshot || !effectiveSnapshot.pointerEvent) && + usedFallback && + entry.items.length + ) { this.scheduleWatchHistoryRepublish(actor, entry.items); } diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index 2bff7e48..0d4dadf0 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -24,8 +24,11 @@ const { nostrClient } = await import("../js/nostr.js"); function createPublishingClient(actorPubkey) { const client = new nostrClient.constructor(); + const publishedEvents = []; + const payloads = []; client.pool = { publish(urls, event) { + publishedEvents.push(event); return { on(eventName, handler) { if (eventName === "ok" || eventName === "seen") { @@ -39,14 +42,14 @@ function createPublishingClient(actorPubkey) { client.pubkey = ""; client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; client.ensureSessionActor = async () => actorPubkey; - client.encryptWatchHistoryPayload = async () => ({ - ok: true, - ciphertext: "ciphertext", - }); + client.encryptWatchHistoryPayload = async (_actor, payload) => { + payloads.push(payload); + return { ok: true, ciphertext: JSON.stringify(payload) }; + }; client.persistWatchHistoryEntry = () => {}; client.cancelWatchHistoryRepublish = () => {}; client.scheduleWatchHistoryRepublish = () => {}; - return client; + return { client, publishedEvents, payloads }; } function createDecryptClient(actorPubkey) { @@ -57,56 +60,102 @@ function createDecryptClient(actorPubkey) { } const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const POINTER_A = { - type: "a", - value: `30078:${ACTOR}:watch-history`, - relay: "wss://relay.unit", -}; -const POINTER_E = { type: "e", value: "event-id-123", relay: null }; -const publishingClient = createPublishingClient(ACTOR); +const { client: publishingClient, publishedEvents, payloads } = + createPublishingClient(ACTOR); + +publishingClient.watchHistoryPayloadMaxBytes = 400; +publishingClient.watchHistoryFetchEventLimit = 10; + +const longPointers = Array.from({ length: 8 }, (_, index) => { + const relay = index % 3 === 0 ? "wss://relay.unit" : null; + if (index % 2 === 0) { + return { + type: "e", + value: `event-${index}-${"x".repeat(90)}`, + relay, + }; + } + return { + type: "a", + value: `30078:${ACTOR}:history-${index}-${"y".repeat(60)}`, + relay, + }; +}); + const publishResult = await publishingClient.publishWatchHistorySnapshot( ACTOR, - [POINTER_A, POINTER_E] + longPointers ); assert.equal(publishResult.ok, true, "publish should report success"); +assert(publishResult.events.length > 1, "snapshot should chunk into multiple events"); +assert.equal( + publishResult.events.length, + payloads.length, + "each chunk should produce an encryption payload" +); -const pointerTags = publishResult.event.tags.filter((tag) => - tag && (tag[0] === "a" || tag[0] === "e") +payloads.forEach((payload) => { + const size = JSON.stringify(payload).length; + assert( + size <= publishingClient.watchHistoryPayloadMaxBytes, + `payload size ${size} should respect cap` + ); + assert.equal(payload.version, 2, "chunk payload should use version 2"); +}); + +const snapshotIds = new Set( + publishResult.events.map((event) => + event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1] + ) +); +assert.equal(snapshotIds.size, 1, "all chunks should share a snapshot id"); + +const headTag = publishResult.event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "head" +); +assert(headTag, "head chunk should include head tag"); + +delete globalThis.window.nostr; + +if (!globalThis.window.NostrTools.nip04) { + globalThis.window.NostrTools.nip04 = {}; +} + +globalThis.window.NostrTools.nip04.decrypt = async ( + _priv, + _pub, + ciphertext +) => ciphertext; + +const decryptClient = createDecryptClient(ACTOR); +decryptClient.relays = ["wss://unit.test"]; +decryptClient.watchHistoryFetchEventLimit = 10; +decryptClient.pool = { + list: async () => publishedEvents, +}; + +const fetched = await decryptClient.fetchWatchHistory(ACTOR); + +assert.equal( + fetched.items.length, + longPointers.length, + "fetch should reassemble all chunked items" ); -assert.deepEqual(pointerTags, [ - ["a", POINTER_A.value, POINTER_A.relay], - ["e", POINTER_E.value], -]); assert.deepEqual( - publishResult.items.map((item) => ({ + fetched.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, })), - [ - { type: "a", value: POINTER_A.value, relay: POINTER_A.relay }, - { type: "e", value: POINTER_E.value, relay: null }, - ] + longPointers.map((item) => ({ + type: item.type, + value: item.value, + relay: item.relay || null, + })), + "chunked fetch should preserve pointer order" ); -delete globalThis.window.nostr; -delete globalThis.window.NostrTools.nip04; - -const decryptClient = createDecryptClient(ACTOR); -const decrypted = await decryptClient.decryptWatchHistoryEvent( - publishResult.event, - ACTOR -); - -assert.deepEqual(decrypted, { - version: 0, - items: [ - { type: "a", value: POINTER_A.value, relay: POINTER_A.relay }, - { type: "e", value: POINTER_E.value, relay: null }, - ], -}); - console.log("watch history tests passed");