Merge pull request #176 from PR0M3TH3AN/codex/update-watch-history-snapshot-handling

Chunk watch history payloads and rebuild fetch pipeline
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-09-30 14:35:07 -04:00
commit 418239f977
4 files changed
+636 -167

No files matched your search

+20
View File
@@ -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;
+4
View File
@@ -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 };
+522 -126
View File
@@ -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);
}
+90 -41
View File
@@ -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");