diff --git a/js/nostr.js b/js/nostr.js index afbbd8bf..580df0c6 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -84,7 +84,7 @@ export { export { normalizePointerInput, pointerKey, - chunkWatchHistoryPayloadItems, + buildWatchHistoryPayload, normalizeActorKey, parseWatchHistoryContentWithFallback, isNip04EncryptedWatchHistoryEvent, diff --git a/js/nostr/client.js b/js/nostr/client.js index 5ba7d0be..9641f176 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -22,7 +22,7 @@ import { createWatchHistoryManager, normalizePointerInput, pointerKey, - chunkWatchHistoryPayloadItems, + buildWatchHistoryPayload, normalizeActorKey, getWatchHistoryCacheTtlMs as getWatchHistoryCacheTtlMsFromManager, getWatchHistoryStorage as getWatchHistoryStorageFromManager, diff --git a/js/nostr/index.js b/js/nostr/index.js index 11fdab61..a2c6714d 100644 --- a/js/nostr/index.js +++ b/js/nostr/index.js @@ -30,7 +30,7 @@ export { export { normalizePointerInput, pointerKey, - chunkWatchHistoryPayloadItems, + buildWatchHistoryPayload, normalizeActorKey, parseWatchHistoryContentWithFallback, isNip04EncryptedWatchHistoryEvent, diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index 7c6cc5d7..c08659b1 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -358,104 +358,86 @@ function parseWatchHistoryPayload(plaintext) { }; } } +const WATCH_HISTORY_MONTH_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/; -export 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 : ""; +function normalizeWatchHistoryPayloadItem(entry, watchedAtMap) { + const pointer = normalizePointerInput(entry); + if (!pointer) { + return null; + } + const key = pointerKey(pointer); + if (!key) { + return null; + } + + const watchedAtSource = + watchedAtMap instanceof Map + ? watchedAtMap.get(key) + : watchedAtMap && typeof watchedAtMap === "object" + ? watchedAtMap[key] + : null; + + const watchedAt = Number.isFinite(watchedAtSource) + ? Math.max(0, Math.floor(watchedAtSource)) + : Number.isFinite(pointer.watchedAt) + ? Math.max(0, Math.floor(pointer.watchedAt)) + : undefined; + + return { + pointer, + id: pointer.value, + watchedAt, + }; +} + +export function buildWatchHistoryPayload(monthString, events, watchedAtMap, maxBytes) { + const normalizedMonth = typeof monthString === "string" ? monthString.trim() : ""; + const validMonth = WATCH_HISTORY_MONTH_PATTERN.test(normalizedMonth); + const safeMax = Math.max(128, Math.floor(maxBytes || WATCH_HISTORY_PAYLOAD_MAX_BYTES || 0)); + const payload = { + version: 2, + month: normalizedMonth, + items: [], + watchedAt: {}, + }; - const chunks = []; const skipped = []; - let current = []; + const included = []; + if (!validMonth) { + skipped.push(...(Array.isArray(events) ? events.filter(Boolean) : [])); + return { payload: null, included, skipped }; + } - 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") { + for (const entry of Array.isArray(events) ? events : []) { + const normalized = normalizeWatchHistoryPayloadItem(entry, watchedAtMap); + if (!normalized) { continue; } - if (!current.length) { - const candidate = [item]; - const size = estimateLength(candidate, chunks.length, chunks.length + 1); - if (size <= measurementLimit) { - current = candidate; - continue; - } - skipped.push(item); + const nextItems = [...payload.items, normalized.id]; + const nextWatchedAt = { ...payload.watchedAt }; + if (Number.isFinite(normalized.watchedAt)) { + nextWatchedAt[normalized.id] = normalized.watchedAt; + } + const candidate = { + version: payload.version, + month: payload.month, + items: nextItems, + watchedAt: nextWatchedAt, + }; + + const payloadSize = JSON.stringify(candidate).length; + if (payloadSize > safeMax) { + skipped.push(normalized.pointer); continue; } - const next = [...current, item]; - const nextSize = estimateLength(next, chunks.length, chunks.length + 1); - if (nextSize <= measurementLimit) { - current = next; - continue; - } - - chunks.push(current); - current = [item]; + payload.items = nextItems; + payload.watchedAt = nextWatchedAt; + included.push(normalized.pointer); } - if (current.length) { - chunks.push(current); - } - - let needsRebalance = true; - while (needsRebalance) { - needsRebalance = false; - for (let index = 0; index < chunks.length; index += 1) { - 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 }; + return { payload, included, skipped }; } export function normalizeActorKey(actor) { @@ -1423,15 +1405,31 @@ class WatchHistoryManager { Array.isArray(rawItems) ? rawItems : [], WATCH_HISTORY_MAX_ITEMS, ); + const monthIdentifier = (() => { + const requestedMonth = + typeof options.monthIdentifier === "string" ? options.monthIdentifier.trim() : ""; + if (WATCH_HISTORY_MONTH_PATTERN.test(requestedMonth)) { + return requestedMonth; + } + const now = new Date(); + const month = String(now.getUTCMonth() + 1).padStart(2, "0"); + return `${now.getUTCFullYear()}-${month}`; + })(); const snapshotId = - typeof options.snapshotId === "string" && options.snapshotId.trim() - ? options.snapshotId.trim() - : `snapshot-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`; - const { chunks, skipped } = chunkWatchHistoryPayloadItems( + (typeof options.snapshotId === "string" && options.snapshotId.trim()) || monthIdentifier; + const { payload, included, skipped } = buildWatchHistoryPayload( + monthIdentifier, canonicalItems, - snapshotId, + options.watchedAtMap || null, WATCH_HISTORY_PAYLOAD_MAX_BYTES, ); + if (!payload) { + devLogger.warn("[nostr] Unable to build watch history payload for month.", { + actor: actorKey, + monthIdentifier, + }); + return { ok: false, error: "invalid-month", retryable: false }; + } if (skipped.length) { devLogger.warn( `[nostr] Watch history snapshot skipped ${skipped.length} oversize ${skipped.length === 1 ? "entry" : "entries" @@ -1460,8 +1458,9 @@ class WatchHistoryManager { devLogger.info("[nostr] Preparing to publish watch history snapshot.", { actor: actorKey, snapshotId, + monthIdentifier, itemCount: canonicalItems.length, - chunkCount: chunks.length, + payloadCount: Array.isArray(payload.items) ? payload.items.length : 0, relaysRequested: relays, attempt: options.attempt || 0, source: options.source || "unknown", @@ -1676,130 +1675,116 @@ class WatchHistoryManager { } return statuses; }; - for (let index = 0; index < chunks.length; index += 1) { - const chunkItems = Array.isArray(chunks[index]) ? chunks[index] : []; - const pointerTags = chunkItems.map((pointer) => { - const tag = [pointer.type === "a" ? "a" : "e", pointer.value]; - if (pointer.relay) { - tag.push(pointer.relay); - } - return tag; - }); - devLogger.info( - "[nostr] Publishing watch history chunk.", - { - actor: actorKey, - snapshotId, - chunkIndex: index, - chunkSize: chunkItems.length, - relays, - }, - ); - const plaintext = JSON.stringify({ - version: 2, - snapshot: snapshotId, - chunkIndex: index, - totalChunks: chunks.length, - items: chunkItems, - }); - const chunkIdentifier = `${snapshotId}:${index}`; - let ciphertext = ""; - let encryptionScheme = ""; - try { - const encryptionResult = await encryptChunkPayload(plaintext, { - chunkIndex: index, - chunkIdentifier, - }); - ciphertext = encryptionResult?.ciphertext || ""; - encryptionScheme = encryptionResult?.scheme || ""; - } catch (error) { - devLogger.warn("[nostr] Failed to encrypt watch history chunk:", { - actor: actorKey, - snapshotId, - chunkIndex: index, - chunkIdentifier, - error: error?.message || error, - attempts: encryptionAttemptErrors.map((entry) => ({ - scheme: entry.scheme, - source: entry.source, - error: entry.error?.message || entry.error, - })), - }); - return { ok: false, error: "encryption-failed", retryable: false }; + const pointerTags = included.map((pointer) => { + const tag = [pointer.type === "a" ? "a" : "e", pointer.value]; + if (pointer.relay) { + tag.push(pointer.relay); } - if (!ciphertext) { - devLogger.warn("[nostr] Encryption produced an empty payload for watch history chunk.", { - actor: actorKey, - snapshotId, - chunkIndex: index, - chunkIdentifier, - scheme: encryptionScheme || null, - }); - return { ok: false, error: "encryption-failed", retryable: false }; - } - const event = buildWatchHistoryChunkEvent({ - pubkey: actorPubkey, - created_at: createdAtCursor, - chunkIdentifier, + return tag; + }); + devLogger.info( + "[nostr] Publishing watch history chunk.", + { + actor: actorKey, snapshotId, - chunkIndex: index, - totalChunks: chunks.length, - pointerTags, - content: ciphertext, - encryption: encryptionScheme, - }); - createdAtCursor += 1; - let signedEvent; - try { - signedEvent = await signEvent(event); - } catch (error) { - devLogger.warn("[nostr] Failed to sign watch history chunk:", error); - return { ok: false, error: "signing-failed", retryable: false }; - } - const publishResults = await publishEventToRelays( - pool, + monthIdentifier, + chunkIndex: 0, + chunkSize: pointerTags.length, relays, - signedEvent, - ); - const relayStatus = formatRelayStatus(publishResults); - const acceptedCount = relayStatus.filter((entry) => entry.success).length; - if (acceptedCount === 0) { - anyChunkRejected = true; - devLogger.warn( - `[nostr] Watch history chunk ${index} rejected by all relays:`, - publishResults, + }, + ); + const plaintext = JSON.stringify(payload); + const chunkIdentifier = `${snapshotId}:0`; + let ciphertext = ""; + let encryptionScheme = ""; + try { + const encryptionResult = await encryptChunkPayload(plaintext, { + chunkIndex: 0, + chunkIdentifier, + }); + ciphertext = encryptionResult?.ciphertext || ""; + encryptionScheme = encryptionResult?.scheme || ""; + } catch (error) { + devLogger.warn("[nostr] Failed to encrypt watch history chunk:", { + actor: actorKey, + snapshotId, + chunkIndex: 0, + chunkIdentifier, + error: error?.message || error, + attempts: encryptionAttemptErrors.map((entry) => ({ + scheme: entry.scheme, + source: entry.source, + error: entry.error?.message || entry.error, + })), + }); + return { ok: false, error: "encryption-failed", retryable: false }; + } + if (!ciphertext) { + devLogger.warn("[nostr] Encryption produced an empty payload for watch history chunk.", { + actor: actorKey, + snapshotId, + chunkIndex: 0, + chunkIdentifier, + scheme: encryptionScheme || null, + }); + return { ok: false, error: "encryption-failed", retryable: false }; + } + const event = buildWatchHistoryChunkEvent({ + pubkey: actorPubkey, + created_at: createdAtCursor, + chunkIdentifier, + snapshotId, + monthIdentifier, + chunkIndex: 0, + totalChunks: 1, + pointerTags, + content: ciphertext, + encryption: encryptionScheme, + }); + createdAtCursor += 1; + let signedEvent; + try { + signedEvent = await signEvent(event); + } catch (error) { + devLogger.warn("[nostr] Failed to sign watch history chunk:", error); + return { ok: false, error: "signing-failed", retryable: false }; + } + const publishResults = await publishEventToRelays(pool, relays, signedEvent); + const relayStatus = formatRelayStatus(publishResults); + const acceptedCount = relayStatus.filter((entry) => entry.success).length; + if (acceptedCount === 0) { + anyChunkRejected = true; + devLogger.warn("[nostr] Watch history chunk rejected by all relays:", publishResults); + } else { + const logMessage = acceptedCount === relays.length ? "accepted" : "partially accepted"; + if (acceptedCount === relays.length) { + devLogger.info( + `[nostr] Watch history chunk accepted by ${acceptedCount}/${relays.length} relay(s).`, ); } else { - const logMessage = - acceptedCount === relays.length ? "accepted" : "partially accepted"; - if (acceptedCount === relays.length) { - devLogger.info( - `[nostr] Watch history chunk ${index} accepted by ${acceptedCount}/${relays.length} relay(s).`, - ); - } else { - anyChunkPartial = true; - devLogger.warn( - `[nostr] Watch history chunk ${index} ${logMessage} by ${acceptedCount}/${relays.length} relay(s).`, - publishResults, - ); - } + anyChunkPartial = true; + devLogger.warn( + `[nostr] Watch history chunk ${logMessage} by ${acceptedCount}/${relays.length} relay(s).`, + publishResults, + ); } - const address = this.deps.eventToAddressPointer?.(signedEvent); - if (address) { - chunkAddresses.push(address); - } - chunkResults.push({ - event: signedEvent, - publishResults, - acceptedCount, - relayStatus, - }); } + const address = this.deps.eventToAddressPointer?.(signedEvent); + if (address) { + chunkAddresses.push(address); + } + chunkResults.push({ + event: signedEvent, + publishResults, + acceptedCount, + relayStatus, + }); const pointerEvent = buildWatchHistoryIndexEvent({ pubkey: actorPubkey, created_at: createdAtCursor, snapshotId, - totalChunks: chunks.length, + monthIdentifier, + totalChunks: 1, chunkAddresses, }); createdAtCursor += 1; diff --git a/tests/nostr/watchHistory.test.js b/tests/nostr/watchHistory.test.js index bc8be49b..8fe7d92d 100644 --- a/tests/nostr/watchHistory.test.js +++ b/tests/nostr/watchHistory.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - chunkWatchHistoryPayloadItems, + buildWatchHistoryPayload, createWatchHistoryManager, fetchWatchHistory, getWatchHistoryCacheTtlMs, @@ -23,8 +23,8 @@ test.beforeEach(() => { ensureLocalStorageCleared(); }); -test("chunkWatchHistoryPayloadItems enforces byte limits and records skipped entries", () => { - const snapshotId = "snapshot-chunk"; +test("buildWatchHistoryPayload enforces byte limits and records skipped entries", () => { + const month = "2025-01"; const maxBytes = 256; const oversizedPointer = { type: "e", @@ -39,9 +39,10 @@ test("chunkWatchHistoryPayloadItems enforces byte limits and records skipped ent })); const payloadItems = [oversizedPointer, ...smallPointers]; - const { chunks, skipped } = chunkWatchHistoryPayloadItems( + const { payload, skipped, included } = buildWatchHistoryPayload( + month, payloadItems, - snapshotId, + null, maxBytes, ); @@ -52,27 +53,28 @@ test("chunkWatchHistoryPayloadItems enforces byte limits and records skipped ent "skipped list should capture the oversized pointer", ); - const flattened = chunks.flat(); + assert.equal(payload.month, month, "payload should record the requested month"); + assert.equal(payload.version, 2, "payload should record the expected version"); assert.deepStrictEqual( - flattened.map((item) => item.value), + payload.items, smallPointers.map((item) => item.value), - "all remaining pointers should be included across chunks", + "all remaining pointers should be included in the payload", + ); + assert.deepStrictEqual( + payload.watchedAt, + smallPointers.reduce((acc, pointer) => ({ ...acc, [pointer.value]: pointer.watchedAt }), {}), + "watchedAt map should mirror included timestamps", + ); + assert.equal( + included.length, + smallPointers.length, + "included pointers should mirror the payload items", + ); + const serializedLength = JSON.stringify(payload).length; + assert( + serializedLength <= maxBytes, + `payload should respect the configured max bytes (observed ${serializedLength})`, ); - assert(chunks.length > 1, "payload should be split across multiple chunks"); - - chunks.forEach((chunkItems, index) => { - const serializedLength = JSON.stringify({ - version: 2, - snapshot: snapshotId, - chunkIndex: index, - totalChunks: chunks.length, - items: chunkItems, - }).length; - assert( - serializedLength <= maxBytes, - `chunk ${index} should respect the configured max bytes (observed ${serializedLength})`, - ); - }); }); test("getWatchHistoryStorage prunes entries that exceed the configured TTL", () => {