mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
Merge pull request #215 from PR0M3TH3AN/codex/update-documentation-and-schemas-for-watch-history
Add dedicated watch history index events
This commit is contained in:
6 files changed
+769
-62
No files matched your search
@@ -63,7 +63,15 @@ export const WATCH_HISTORY_KIND = 30078;
|
||||
* BitVid deployments that share relays, but keep it stable once clients begin
|
||||
* syncing history.
|
||||
*/
|
||||
export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history";
|
||||
export const WATCH_HISTORY_LIST_IDENTIFIER = "watch-history:v2:index";
|
||||
|
||||
/**
|
||||
* Legacy identifiers that BitVid clients should continue honoring when
|
||||
* fetching historical watch-history snapshots.
|
||||
*/
|
||||
export const WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS = Object.freeze([
|
||||
"watch-history",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Maximum number of watch-history entries to retain per user.
|
||||
|
||||
@@ -49,7 +49,8 @@ read/write split.
|
||||
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
|
||||
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
|
||||
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | `['t','view']`, `['video', <pointer id>]`, pointer tag (`['a', ...]` or `['e', ...]`), optional dedupe `['d', <scope>]`, optional `['session','true']` when a session actor signs, plus any extra debugging tags | Optional plaintext message |
|
||||
| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', <chunk identifier>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, pointer tags for each entry, `['head','1']` + `['a', <address>]` pointers on the first chunk | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
|
||||
| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', <id>]`, `['chunks', <total>]`, repeated `['a', <address>]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` |
|
||||
| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', <chunk identifier>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) |
|
||||
| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` |
|
||||
| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` |
|
||||
| Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
@@ -59,3 +60,17 @@ read/write split.
|
||||
If you introduce a new Nostr feature, add its schema to
|
||||
`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing
|
||||
builders inherit the same debugging knobs.
|
||||
|
||||
### Watch history identifiers
|
||||
|
||||
Watch-history snapshots now publish a dedicated index event with
|
||||
`#d ${WATCH_HISTORY_LIST_IDENTIFIER}` (defaults to `watch-history:v2:index`). The
|
||||
index advertises the active snapshot id, total chunk count, and a pointer to
|
||||
each chunk via `a` tags so clients can fetch the encrypted payloads without
|
||||
guessing identifiers.
|
||||
|
||||
Every chunk event uses a unique `d` tag such as
|
||||
`watch-history:v2/<snapshot>/<chunkIndex>`. Legacy snapshots created before the
|
||||
index event shipped stored the first chunk at `#d watch-history`; new clients
|
||||
should continue reading that identifier for backward compatibility when
|
||||
hydrating older histories.
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_WHITELIST_MODE_ENABLED,
|
||||
WATCH_HISTORY_KIND,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
|
||||
WATCH_HISTORY_MAX_ITEMS,
|
||||
WATCH_HISTORY_BATCH_RESOLVE,
|
||||
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
|
||||
@@ -59,6 +60,7 @@ export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
|
||||
|
||||
export { WATCH_HISTORY_KIND };
|
||||
export { WATCH_HISTORY_LIST_IDENTIFIER };
|
||||
export { WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS };
|
||||
export { WATCH_HISTORY_MAX_ITEMS };
|
||||
export { WATCH_HISTORY_BATCH_RESOLVE };
|
||||
export { WATCH_HISTORY_PAYLOAD_MAX_BYTES };
|
||||
|
||||
+485
-32
@@ -4,6 +4,7 @@ import {
|
||||
isDevMode,
|
||||
WATCH_HISTORY_KIND,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS,
|
||||
WATCH_HISTORY_MAX_ITEMS,
|
||||
WATCH_HISTORY_BATCH_RESOLVE,
|
||||
WATCH_HISTORY_PAYLOAD_MAX_BYTES,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
buildVideoPostEvent,
|
||||
buildVideoMirrorEvent,
|
||||
buildViewEvent,
|
||||
buildWatchHistoryIndexEvent,
|
||||
buildWatchHistoryChunkEvent,
|
||||
} from "./nostrEventSchemas.js";
|
||||
|
||||
@@ -46,6 +48,76 @@ const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE =
|
||||
const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1";
|
||||
const WATCH_HISTORY_CACHE_STORAGE_KEY = "bitvid:watchHistoryCache:v1";
|
||||
|
||||
const WATCH_HISTORY_INDEX_IDENTIFIER_LOWER =
|
||||
typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
|
||||
? WATCH_HISTORY_LIST_IDENTIFIER.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
const WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX = (() => {
|
||||
const rawIdentifier =
|
||||
typeof WATCH_HISTORY_LIST_IDENTIFIER === "string"
|
||||
? WATCH_HISTORY_LIST_IDENTIFIER.trim()
|
||||
: "";
|
||||
const suffixes = [":index", "/index"];
|
||||
for (const suffix of suffixes) {
|
||||
if (rawIdentifier.toLowerCase().endsWith(suffix)) {
|
||||
return rawIdentifier
|
||||
.slice(0, rawIdentifier.length - suffix.length)
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
return rawIdentifier.replace(/\/+$/, "") || "watch-history";
|
||||
})();
|
||||
|
||||
const LEGACY_WATCH_HISTORY_IDENTIFIER_SET = (() => {
|
||||
const identifiers = Array.isArray(WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS)
|
||||
? WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS
|
||||
: [];
|
||||
const normalized = new Set(
|
||||
identifiers
|
||||
.map((value) =>
|
||||
typeof value === "string" ? value.trim().toLowerCase() : ""
|
||||
)
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (!normalized.size) {
|
||||
normalized.add("watch-history");
|
||||
}
|
||||
return normalized;
|
||||
})();
|
||||
|
||||
function deriveWatchHistoryChunkIdentifier(snapshotId, chunkIndex) {
|
||||
const prefix = WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX || "watch-history";
|
||||
const sanitizedPrefix = prefix.replace(/\/+$/, "");
|
||||
const safeSnapshot =
|
||||
typeof snapshotId === "string" && snapshotId ? snapshotId : "0";
|
||||
const safeIndex = Number.isFinite(chunkIndex)
|
||||
? Math.max(0, Math.floor(chunkIndex))
|
||||
: 0;
|
||||
return `${sanitizedPrefix}/${safeSnapshot}/${safeIndex}`;
|
||||
}
|
||||
|
||||
function isIndexIdentifier(identifier) {
|
||||
if (!identifier || typeof identifier !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (!WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) {
|
||||
return false;
|
||||
}
|
||||
return identifier.trim().toLowerCase() === WATCH_HISTORY_INDEX_IDENTIFIER_LOWER;
|
||||
}
|
||||
|
||||
function isLegacyHeadIdentifier(identifier) {
|
||||
if (!identifier || typeof identifier !== "string") {
|
||||
return false;
|
||||
}
|
||||
const normalized = identifier.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return LEGACY_WATCH_HISTORY_IDENTIFIER_SET.has(normalized);
|
||||
}
|
||||
|
||||
// To limit error spam
|
||||
let errorLogCount = 0;
|
||||
const MAX_ERROR_LOGS = 100;
|
||||
@@ -1930,9 +2002,7 @@ class NostrClient {
|
||||
|
||||
const totalChunks = chunkItemsList.length || 1;
|
||||
const chunkIdentifiers = chunkItemsList.map((_, index) =>
|
||||
index === 0
|
||||
? WATCH_HISTORY_LIST_IDENTIFIER
|
||||
: `watch-history:${snapshotId}:${index}`
|
||||
deriveWatchHistoryChunkIdentifier(snapshotId, index)
|
||||
);
|
||||
const chunkAddresses = chunkIdentifiers.map(
|
||||
(identifier) => `${WATCH_HISTORY_KIND}:${normalizedActor}:${identifier}`
|
||||
@@ -2002,7 +2072,8 @@ class NostrClient {
|
||||
return tag;
|
||||
});
|
||||
|
||||
const chunkIdentifier = chunkIdentifiers[index] || WATCH_HISTORY_LIST_IDENTIFIER;
|
||||
const chunkIdentifier =
|
||||
chunkIdentifiers[index] ?? deriveWatchHistoryChunkIdentifier(snapshotId, index);
|
||||
|
||||
// Ensure created_at stays monotonic so chunk 0 always outranks prior snapshots
|
||||
// even when multiple publishes land within the same wall-clock second.
|
||||
@@ -2014,7 +2085,6 @@ class NostrClient {
|
||||
chunkIndex: index,
|
||||
totalChunks,
|
||||
pointerTags,
|
||||
chunkAddresses,
|
||||
content: encryptionResult.ciphertext,
|
||||
});
|
||||
|
||||
@@ -2065,6 +2135,50 @@ class NostrClient {
|
||||
});
|
||||
}
|
||||
|
||||
const indexEvent = buildWatchHistoryIndexEvent({
|
||||
pubkey: normalizedActor,
|
||||
created_at: baseTimestamp + totalChunks,
|
||||
snapshotId,
|
||||
totalChunks,
|
||||
chunkAddresses,
|
||||
});
|
||||
|
||||
let signedIndexEvent = null;
|
||||
try {
|
||||
if (useExtension) {
|
||||
signedIndexEvent = await window.nostr.signEvent(indexEvent);
|
||||
} else {
|
||||
signedIndexEvent = signEventWithPrivateKey(
|
||||
indexEvent,
|
||||
this.sessionActor.privateKey
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[nostr] Failed to sign watch history index event:",
|
||||
error
|
||||
);
|
||||
signedIndexEvent = null;
|
||||
}
|
||||
|
||||
let indexPublishResults = [];
|
||||
let indexSuccess = false;
|
||||
if (signedIndexEvent) {
|
||||
indexPublishResults = await Promise.all(
|
||||
relays.map((url) => publishEventToRelay(this.pool, url, signedIndexEvent))
|
||||
);
|
||||
indexSuccess = indexPublishResults.some((result) => result.success);
|
||||
if (!indexSuccess) {
|
||||
overallSuccess = false;
|
||||
console.warn(
|
||||
"[nostr] Failed to publish watch history index:",
|
||||
indexPublishResults
|
||||
);
|
||||
}
|
||||
} else {
|
||||
overallSuccess = false;
|
||||
}
|
||||
|
||||
if (overallSuccess) {
|
||||
this.cancelWatchHistoryRepublish(normalizedActor);
|
||||
} else if (allowRetry && persistedItems.length) {
|
||||
@@ -2074,23 +2188,91 @@ class NostrClient {
|
||||
const baselineEntry =
|
||||
existingEntry || this.watchHistoryCache.get(normalizedActor) || null;
|
||||
|
||||
const headEvent = chunkResults[0]?.event || null;
|
||||
const pointerEvent = signedIndexEvent || chunkResults[0]?.event || null;
|
||||
const newEntry = this.createWatchHistoryEntry(
|
||||
headEvent,
|
||||
pointerEvent,
|
||||
persistedItems,
|
||||
Date.now(),
|
||||
baselineEntry
|
||||
);
|
||||
|
||||
if (!(newEntry.chunkEvents instanceof Map)) {
|
||||
newEntry.chunkEvents = new Map();
|
||||
} else {
|
||||
newEntry.chunkEvents.clear();
|
||||
}
|
||||
|
||||
const chunkPointerItems = [];
|
||||
const chunkPointerKeys = new Set();
|
||||
|
||||
chunkResults.forEach((chunk) => {
|
||||
const address = eventToAddressPointer(chunk.event);
|
||||
if (!address) {
|
||||
return;
|
||||
}
|
||||
const pointer = clonePointerItem({ type: "a", value: address, relay: null });
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
const key = pointerKey(pointer);
|
||||
if (!key || chunkPointerKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
chunkPointerKeys.add(key);
|
||||
chunkPointerItems.push(pointer);
|
||||
const clonedChunk = cloneEventForCache(chunk.event);
|
||||
newEntry.chunkEvents.set(key, clonedChunk || chunk.event);
|
||||
});
|
||||
|
||||
let indexPointer = null;
|
||||
if (signedIndexEvent) {
|
||||
const indexAddress = eventToAddressPointer(signedIndexEvent);
|
||||
if (indexAddress) {
|
||||
const pointer = clonePointerItem({
|
||||
type: "a",
|
||||
value: indexAddress,
|
||||
relay: null,
|
||||
});
|
||||
if (pointer) {
|
||||
const key = pointerKey(pointer);
|
||||
if (key) {
|
||||
chunkPointerKeys.add(key);
|
||||
const clonedIndex = cloneEventForCache(signedIndexEvent);
|
||||
newEntry.chunkEvents.set(key, clonedIndex || signedIndexEvent);
|
||||
}
|
||||
indexPointer = pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newEntry.chunkPointers = {
|
||||
head: indexPointer || chunkPointerItems[0] || null,
|
||||
index: indexPointer,
|
||||
chunks: chunkPointerItems,
|
||||
};
|
||||
|
||||
this.watchHistoryCache.set(normalizedActor, newEntry);
|
||||
this.persistWatchHistoryEntry(normalizedActor, newEntry);
|
||||
|
||||
const combinedEvents = [];
|
||||
const combinedResults = [];
|
||||
if (signedIndexEvent) {
|
||||
combinedEvents.push(signedIndexEvent);
|
||||
combinedResults.push(indexPublishResults);
|
||||
}
|
||||
chunkResults.forEach((chunk) => {
|
||||
combinedEvents.push(chunk.event);
|
||||
combinedResults.push(chunk.results);
|
||||
});
|
||||
|
||||
return {
|
||||
ok: overallSuccess,
|
||||
event: headEvent,
|
||||
events: chunkResults.map((chunk) => chunk.event),
|
||||
event: pointerEvent,
|
||||
indexEvent: signedIndexEvent,
|
||||
events: combinedEvents,
|
||||
chunks: chunkResults,
|
||||
results: chunkResults.map((chunk) => chunk.results),
|
||||
results: combinedResults,
|
||||
indexResults: indexPublishResults,
|
||||
snapshot: snapshotId,
|
||||
items: newEntry.items
|
||||
.map((item) => clonePointerItem(item))
|
||||
@@ -2819,13 +3001,34 @@ class NostrClient {
|
||||
: WATCH_HISTORY_FETCH_EVENT_LIMIT;
|
||||
const fetchLimit = Math.max(1, Math.floor(fetchLimitCandidate));
|
||||
|
||||
const headIdentifierSet = new Set();
|
||||
if (
|
||||
typeof WATCH_HISTORY_LIST_IDENTIFIER === "string" &&
|
||||
WATCH_HISTORY_LIST_IDENTIFIER.trim()
|
||||
) {
|
||||
headIdentifierSet.add(WATCH_HISTORY_LIST_IDENTIFIER.trim());
|
||||
}
|
||||
if (Array.isArray(WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS)) {
|
||||
WATCH_HISTORY_LEGACY_LIST_IDENTIFIERS.forEach((value) => {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
headIdentifierSet.add(value.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!headIdentifierSet.size && WATCH_HISTORY_INDEX_IDENTIFIER_LOWER) {
|
||||
headIdentifierSet.add(WATCH_HISTORY_LIST_IDENTIFIER);
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [WATCH_HISTORY_KIND],
|
||||
authors: [actor],
|
||||
"#d": [WATCH_HISTORY_LIST_IDENTIFIER],
|
||||
limit: fetchLimit,
|
||||
};
|
||||
|
||||
if (headIdentifierSet.size) {
|
||||
filter["#d"] = Array.from(headIdentifierSet);
|
||||
}
|
||||
|
||||
let fetchedHeadEvents = [];
|
||||
try {
|
||||
const events = await this.pool.list(relayList, [filter]);
|
||||
@@ -2864,6 +3067,7 @@ class NostrClient {
|
||||
|
||||
const chunkIdentifiers = new Set();
|
||||
const snapshotIds = new Set();
|
||||
const indexSnapshots = new Map();
|
||||
|
||||
for (const event of fetchedHeadEvents) {
|
||||
if (!event || typeof event !== "object") {
|
||||
@@ -2871,15 +3075,29 @@ class NostrClient {
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
let identifierTagValue = "";
|
||||
let snapshotTagValue = "";
|
||||
let totalChunksTagValue = null;
|
||||
const pointerIdentifiers = new Set();
|
||||
const pointerAddresses = new Set();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
if (tag[0] === "snapshot" && typeof tag[1] === "string" && tag[1]) {
|
||||
snapshotIds.add(tag[1]);
|
||||
if (tag[0] === "d" && typeof tag[1] === "string") {
|
||||
identifierTagValue = tag[1];
|
||||
} else if (tag[0] === "snapshot" && typeof tag[1] === "string" && tag[1]) {
|
||||
snapshotTagValue = tag[1];
|
||||
} else if (tag[0] === "chunks") {
|
||||
const parsedTotal = Number.parseInt(tag[1], 10);
|
||||
if (Number.isFinite(parsedTotal)) {
|
||||
totalChunksTagValue = Math.max(0, parsedTotal);
|
||||
}
|
||||
} else if (tag[0] === "a") {
|
||||
const pointer = normalizePointerTag(tag);
|
||||
if (pointer?.type === "a") {
|
||||
if (pointer?.type === "a" && typeof pointer.value === "string") {
|
||||
pointerAddresses.add(pointer.value);
|
||||
const [kindStr, pubkey, identifier] = pointer.value.split(":");
|
||||
const kind = Number.parseInt(kindStr, 10);
|
||||
if (
|
||||
@@ -2889,13 +3107,119 @@ class NostrClient {
|
||||
pubkey === actor &&
|
||||
typeof identifier === "string" &&
|
||||
identifier &&
|
||||
identifier !== WATCH_HISTORY_LIST_IDENTIFIER
|
||||
!isIndexIdentifier(identifier)
|
||||
) {
|
||||
chunkIdentifiers.add(identifier);
|
||||
pointerIdentifiers.add(identifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const identifier = typeof identifierTagValue === "string" ? identifierTagValue : "";
|
||||
let snapshotId = snapshotTagValue;
|
||||
let totalChunks =
|
||||
Number.isFinite(totalChunksTagValue) && totalChunksTagValue !== null
|
||||
? Math.max(0, Math.floor(totalChunksTagValue))
|
||||
: null;
|
||||
|
||||
const isIndexEvent = isIndexIdentifier(identifier);
|
||||
|
||||
if (isIndexEvent && typeof event.content === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(event.content);
|
||||
if (!snapshotId && typeof parsed?.snapshot === "string" && parsed.snapshot) {
|
||||
snapshotId = parsed.snapshot;
|
||||
}
|
||||
if (
|
||||
totalChunks === null &&
|
||||
Number.isFinite(Number.parseInt(parsed?.totalChunks, 10))
|
||||
) {
|
||||
totalChunks = Math.max(0, Math.floor(Number.parseInt(parsed.totalChunks, 10)));
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to parse watch history index payload:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshotId) {
|
||||
snapshotId = typeof event.id === "string" && event.id ? event.id : "";
|
||||
}
|
||||
|
||||
if (snapshotId) {
|
||||
snapshotIds.add(snapshotId);
|
||||
}
|
||||
|
||||
pointerIdentifiers.forEach((value) => {
|
||||
if (value) {
|
||||
chunkIdentifiers.add(value);
|
||||
}
|
||||
});
|
||||
|
||||
if (identifier && !isIndexEvent) {
|
||||
chunkIdentifiers.add(identifier);
|
||||
}
|
||||
|
||||
if (isIndexEvent && snapshotId) {
|
||||
const existingMeta = indexSnapshots.get(snapshotId) || {
|
||||
event: null,
|
||||
totalChunks: null,
|
||||
chunkIdentifiers: new Set(),
|
||||
chunkAddresses: new Set(),
|
||||
};
|
||||
|
||||
const existingCreatedAt =
|
||||
typeof existingMeta.event?.created_at === "number"
|
||||
? existingMeta.event.created_at
|
||||
: 0;
|
||||
const candidateCreatedAt =
|
||||
typeof event.created_at === "number" ? event.created_at : 0;
|
||||
if (!existingMeta.event || candidateCreatedAt >= existingCreatedAt) {
|
||||
existingMeta.event = event;
|
||||
}
|
||||
|
||||
if (Number.isFinite(totalChunks)) {
|
||||
if (
|
||||
!Number.isFinite(existingMeta.totalChunks) ||
|
||||
totalChunks > existingMeta.totalChunks
|
||||
) {
|
||||
existingMeta.totalChunks = totalChunks;
|
||||
}
|
||||
}
|
||||
|
||||
pointerIdentifiers.forEach((value) => {
|
||||
if (value) {
|
||||
existingMeta.chunkIdentifiers.add(value);
|
||||
}
|
||||
});
|
||||
|
||||
pointerAddresses.forEach((value) => {
|
||||
if (value) {
|
||||
existingMeta.chunkAddresses.add(value);
|
||||
}
|
||||
});
|
||||
|
||||
indexSnapshots.set(snapshotId, existingMeta);
|
||||
} else if (snapshotId) {
|
||||
const existingMeta = indexSnapshots.get(snapshotId);
|
||||
if (existingMeta) {
|
||||
pointerIdentifiers.forEach((value) => {
|
||||
if (value) {
|
||||
existingMeta.chunkIdentifiers.add(value);
|
||||
}
|
||||
});
|
||||
pointerAddresses.forEach((value) => {
|
||||
if (value) {
|
||||
existingMeta.chunkAddresses.add(value);
|
||||
}
|
||||
});
|
||||
indexSnapshots.set(snapshotId, existingMeta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunkFilters = [];
|
||||
@@ -2981,11 +3305,15 @@ class NostrClient {
|
||||
let taggedSnapshotId = "";
|
||||
let taggedChunkIndex = null;
|
||||
let taggedTotalChunks = null;
|
||||
let identifierTagValue = "";
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
if (tag[0] === "d" && typeof tag[1] === "string") {
|
||||
identifierTagValue = tag[1];
|
||||
}
|
||||
if (tag[0] === "snapshot" && typeof tag[1] === "string") {
|
||||
taggedSnapshotId = tag[1];
|
||||
} else if (tag[0] === "chunk") {
|
||||
@@ -3035,6 +3363,9 @@ class NostrClient {
|
||||
latestCreatedAt:
|
||||
typeof event.created_at === "number" ? event.created_at : 0,
|
||||
version: payload.version || 0,
|
||||
indexEvent: null,
|
||||
chunkAddresses: new Set(),
|
||||
expectedChunkIdentifiers: new Set(),
|
||||
};
|
||||
|
||||
bucket.chunks.set(chunkIndex, {
|
||||
@@ -3054,6 +3385,58 @@ class NostrClient {
|
||||
bucket.latestCreatedAt = createdAt;
|
||||
}
|
||||
bucket.version = Math.max(bucket.version, payload.version || 0);
|
||||
const chunkAddress = eventToAddressPointer(event);
|
||||
if (chunkAddress) {
|
||||
bucket.chunkAddresses.add(chunkAddress);
|
||||
}
|
||||
if (identifierTagValue) {
|
||||
bucket.expectedChunkIdentifiers.add(identifierTagValue);
|
||||
}
|
||||
snapshotMap.set(snapshotId, bucket);
|
||||
}
|
||||
|
||||
for (const [snapshotId, meta] of indexSnapshots.entries()) {
|
||||
const bucket = snapshotMap.get(snapshotId) || {
|
||||
snapshotId,
|
||||
chunks: new Map(),
|
||||
expectedChunks: 0,
|
||||
latestCreatedAt: 0,
|
||||
version: 0,
|
||||
indexEvent: null,
|
||||
chunkAddresses: new Set(),
|
||||
expectedChunkIdentifiers: new Set(),
|
||||
};
|
||||
|
||||
if (meta.event) {
|
||||
const createdAt =
|
||||
typeof meta.event.created_at === "number" ? meta.event.created_at : 0;
|
||||
if (createdAt > bucket.latestCreatedAt) {
|
||||
bucket.latestCreatedAt = createdAt;
|
||||
}
|
||||
bucket.indexEvent = meta.event;
|
||||
}
|
||||
|
||||
if (Number.isFinite(meta.totalChunks)) {
|
||||
const normalized = Math.max(1, Math.floor(meta.totalChunks));
|
||||
bucket.expectedChunks = Math.max(bucket.expectedChunks, normalized);
|
||||
}
|
||||
|
||||
if (meta.chunkAddresses instanceof Set) {
|
||||
meta.chunkAddresses.forEach((value) => {
|
||||
if (typeof value === "string" && value) {
|
||||
bucket.chunkAddresses.add(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (meta.chunkIdentifiers instanceof Set) {
|
||||
meta.chunkIdentifiers.forEach((value) => {
|
||||
if (typeof value === "string" && value) {
|
||||
bucket.expectedChunkIdentifiers.add(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
snapshotMap.set(snapshotId, bucket);
|
||||
}
|
||||
|
||||
@@ -3070,7 +3453,7 @@ class NostrClient {
|
||||
);
|
||||
const seenKeys = new Set();
|
||||
const combinedItems = [];
|
||||
let pointerCandidate = null;
|
||||
let pointerCandidate = bucket.indexEvent || null;
|
||||
|
||||
for (const index of chunkIndices) {
|
||||
const chunk = bucket.chunks.get(index);
|
||||
@@ -3105,6 +3488,12 @@ class NostrClient {
|
||||
version: bucket.version,
|
||||
chunkCount: chunkIndices.length,
|
||||
expectedChunks: Math.max(bucket.expectedChunks, chunkIndices.length || 1),
|
||||
chunkAddresses: bucket.chunkAddresses instanceof Set
|
||||
? new Set(bucket.chunkAddresses)
|
||||
: new Set(),
|
||||
expectedChunkIdentifiers: bucket.expectedChunkIdentifiers instanceof Set
|
||||
? new Set(bucket.expectedChunkIdentifiers)
|
||||
: new Set(),
|
||||
};
|
||||
|
||||
if (!fallbackSnapshot) {
|
||||
@@ -3168,8 +3557,31 @@ class NostrClient {
|
||||
}
|
||||
|
||||
const chunkPointerItemsForEntry = [];
|
||||
let headPointerForEntry = null;
|
||||
const chunkPointerKeys = new Set();
|
||||
let indexPointerForEntry = null;
|
||||
let firstChunkPointer = null;
|
||||
|
||||
if (effectiveSnapshot?.pointerEvent) {
|
||||
const indexAddress = eventToAddressPointer(effectiveSnapshot.pointerEvent);
|
||||
if (indexAddress) {
|
||||
const pointer = clonePointerItem({
|
||||
type: "a",
|
||||
value: indexAddress,
|
||||
relay: null,
|
||||
});
|
||||
if (pointer) {
|
||||
const key = pointerKey(pointer);
|
||||
if (key) {
|
||||
chunkPointerKeys.add(key);
|
||||
const clonedIndex = cloneEventForCache(effectiveSnapshot.pointerEvent);
|
||||
if (clonedIndex) {
|
||||
entry.chunkEvents.set(key, clonedIndex);
|
||||
}
|
||||
indexPointerForEntry = pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSnapshot?.snapshotId) {
|
||||
const bucketForEntry = snapshotMap.get(effectiveSnapshot.snapshotId);
|
||||
@@ -3194,7 +3606,7 @@ class NostrClient {
|
||||
continue;
|
||||
}
|
||||
const key = pointerKey(pointer);
|
||||
if (!key) {
|
||||
if (!key || chunkPointerKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const clonedChunkEvent = cloneEventForCache(chunk.event);
|
||||
@@ -3202,25 +3614,57 @@ class NostrClient {
|
||||
entry.chunkEvents.set(key, clonedChunkEvent);
|
||||
}
|
||||
chunkPointerKeys.add(key);
|
||||
if (chunk.payload?.chunkIndex === 0) {
|
||||
headPointerForEntry = pointer;
|
||||
} else {
|
||||
chunkPointerItemsForEntry.push(pointer);
|
||||
chunkPointerItemsForEntry.push(pointer);
|
||||
if (chunk.payload?.chunkIndex === 0 && !firstChunkPointer) {
|
||||
firstChunkPointer = pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveSnapshot?.chunkAddresses instanceof Set) {
|
||||
effectiveSnapshot.chunkAddresses.forEach((address) => {
|
||||
const pointer = clonePointerItem({
|
||||
type: "a",
|
||||
value: address,
|
||||
relay: null,
|
||||
});
|
||||
if (!pointer) {
|
||||
return;
|
||||
}
|
||||
const key = pointerKey(pointer);
|
||||
if (!key || chunkPointerKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
chunkPointerKeys.add(key);
|
||||
chunkPointerItemsForEntry.push(pointer);
|
||||
});
|
||||
}
|
||||
|
||||
const headPointer = indexPointerForEntry || firstChunkPointer || null;
|
||||
entry.chunkPointers = {
|
||||
head: headPointerForEntry,
|
||||
head: headPointer,
|
||||
index: indexPointerForEntry,
|
||||
chunks: chunkPointerItemsForEntry,
|
||||
};
|
||||
|
||||
const headPointerKey = headPointerForEntry ? pointerKey(headPointerForEntry) : "";
|
||||
if (headPointerKey) {
|
||||
chunkPointerKeys.add(headPointerKey);
|
||||
if (entry.pointerEvent) {
|
||||
entry.chunkEvents.set(headPointerKey, cloneEventForCache(entry.pointerEvent));
|
||||
if (headPointer) {
|
||||
const headPointerKey = pointerKey(headPointer);
|
||||
if (headPointerKey) {
|
||||
chunkPointerKeys.add(headPointerKey);
|
||||
const indexPointerKey = indexPointerForEntry
|
||||
? pointerKey(indexPointerForEntry)
|
||||
: "";
|
||||
if (
|
||||
entry.pointerEvent &&
|
||||
indexPointerKey &&
|
||||
headPointerKey === indexPointerKey
|
||||
) {
|
||||
entry.chunkEvents.set(
|
||||
headPointerKey,
|
||||
cloneEventForCache(entry.pointerEvent)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3320,11 +3764,20 @@ class NostrClient {
|
||||
if (!normalizedIdentifier) {
|
||||
return "none";
|
||||
}
|
||||
const normalizedIdentifierLower = normalizedIdentifier.toLowerCase();
|
||||
if (normalizedIdentifierLower === WATCH_HISTORY_LIST_IDENTIFIER) {
|
||||
if (isIndexIdentifier(normalizedIdentifier)) {
|
||||
return "head";
|
||||
}
|
||||
if (!normalizedIdentifierLower.startsWith("watch-history:")) {
|
||||
if (isLegacyHeadIdentifier(normalizedIdentifier)) {
|
||||
return "head";
|
||||
}
|
||||
const chunkPrefixLower = WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX
|
||||
? WATCH_HISTORY_CHUNK_IDENTIFIER_PREFIX.trim().toLowerCase()
|
||||
: "";
|
||||
const normalizedIdentifierLower = normalizedIdentifier.toLowerCase();
|
||||
const matchesPrefix = chunkPrefixLower
|
||||
? normalizedIdentifierLower.startsWith(chunkPrefixLower)
|
||||
: normalizedIdentifierLower.startsWith("watch-history");
|
||||
if (!matchesPrefix) {
|
||||
return "none";
|
||||
}
|
||||
const normalizedOwner =
|
||||
|
||||
+86
-1
@@ -10,6 +10,7 @@ export const NOTE_TYPES = Object.freeze({
|
||||
VIDEO_MIRROR: "videoMirror",
|
||||
RELAY_LIST: "relayList",
|
||||
VIEW_EVENT: "viewEvent",
|
||||
WATCH_HISTORY_INDEX: "watchHistoryIndex",
|
||||
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
|
||||
SUBSCRIPTION_LIST: "subscriptionList",
|
||||
USER_BLOCK_LIST: "userBlockList",
|
||||
@@ -91,13 +92,30 @@ const BASE_SCHEMAS = {
|
||||
description: "Optional plaintext content used for diagnostics.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY_INDEX]: {
|
||||
type: NOTE_TYPES.WATCH_HISTORY_INDEX,
|
||||
label: "Watch history index",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
},
|
||||
snapshotTagName: "snapshot",
|
||||
totalTagName: "chunks",
|
||||
chunkPointerTagName: "a",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "json",
|
||||
description:
|
||||
"JSON payload describing the active snapshot and chunk count.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY_CHUNK]: {
|
||||
type: NOTE_TYPES.WATCH_HISTORY_CHUNK,
|
||||
label: "Watch history snapshot",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
identifierTag: {
|
||||
name: "d",
|
||||
value: WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
},
|
||||
encryptionTag: { name: "encrypted", value: "nip04" },
|
||||
snapshotTagName: "snapshot",
|
||||
@@ -454,6 +472,73 @@ export function buildViewEvent({
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWatchHistoryIndexEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
snapshotId,
|
||||
totalChunks,
|
||||
chunkAddresses = [],
|
||||
additionalTags = [],
|
||||
content,
|
||||
}) {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX);
|
||||
const tags = [];
|
||||
const identifierName = schema?.identifierTag?.name || "d";
|
||||
const identifierValue = schema?.identifierTag?.value || WATCH_HISTORY_LIST_IDENTIFIER;
|
||||
if (identifierName && identifierValue) {
|
||||
tags.push([identifierName, identifierValue]);
|
||||
}
|
||||
const snapshotTagName = schema?.snapshotTagName || "snapshot";
|
||||
if (snapshotTagName && snapshotId) {
|
||||
tags.push([snapshotTagName, snapshotId]);
|
||||
}
|
||||
const totalTagName = schema?.totalTagName || "chunks";
|
||||
if (totalTagName && Number.isFinite(totalChunks)) {
|
||||
tags.push([totalTagName, String(Math.max(0, Math.floor(totalChunks)))]);
|
||||
}
|
||||
const pointerTagName = schema?.chunkPointerTagName || "a";
|
||||
chunkAddresses.forEach((address) => {
|
||||
if (typeof address === "string" && address) {
|
||||
tags.push([pointerTagName, address]);
|
||||
}
|
||||
});
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
if (Array.isArray(additionalTags)) {
|
||||
additionalTags.forEach((tag) => {
|
||||
if (Array.isArray(tag) && tag.length >= 2) {
|
||||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let resolvedContent = content;
|
||||
if (resolvedContent === undefined) {
|
||||
const payload = {};
|
||||
if (snapshotId) {
|
||||
payload.snapshot = snapshotId;
|
||||
}
|
||||
if (Number.isFinite(totalChunks)) {
|
||||
payload.totalChunks = Math.max(0, Math.floor(totalChunks));
|
||||
}
|
||||
resolvedContent = JSON.stringify(payload);
|
||||
} else if (typeof resolvedContent !== "string") {
|
||||
resolvedContent = JSON.stringify(resolvedContent ?? {});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content:
|
||||
typeof resolvedContent === "string"
|
||||
? resolvedContent
|
||||
: String(resolvedContent ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWatchHistoryChunkEvent({
|
||||
pubkey,
|
||||
created_at,
|
||||
|
||||
+171
-27
@@ -5,6 +5,13 @@ const {
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
WATCH_HISTORY_KIND,
|
||||
} = await import("../js/config.js");
|
||||
const {
|
||||
NOTE_TYPES,
|
||||
getNostrEventSchema,
|
||||
setNostrEventSchemaOverrides,
|
||||
buildWatchHistoryIndexEvent,
|
||||
buildWatchHistoryChunkEvent,
|
||||
} = await import("../js/nostrEventSchemas.js");
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
@@ -66,6 +73,132 @@ function createDecryptClient(actorPubkey) {
|
||||
}
|
||||
|
||||
const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const WATCH_HISTORY_CHUNK_PREFIX = WATCH_HISTORY_LIST_IDENTIFIER.replace(
|
||||
/[:/]index$/i,
|
||||
""
|
||||
);
|
||||
|
||||
const indexSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX);
|
||||
assert(indexSchema, "index schema should be available");
|
||||
|
||||
const chunkSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK);
|
||||
assert(chunkSchema, "chunk schema should be available");
|
||||
|
||||
const sampleChunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/sample/0`;
|
||||
const sampleChunkAddresses = [
|
||||
`${WATCH_HISTORY_KIND}:${ACTOR}:${sampleChunkIdentifier}`,
|
||||
`${WATCH_HISTORY_KIND}:${ACTOR}:${WATCH_HISTORY_CHUNK_PREFIX}/sample/1`,
|
||||
];
|
||||
|
||||
const sampleIndexEvent = buildWatchHistoryIndexEvent({
|
||||
pubkey: ACTOR,
|
||||
created_at: 111,
|
||||
snapshotId: "sample",
|
||||
totalChunks: 2,
|
||||
chunkAddresses: sampleChunkAddresses,
|
||||
});
|
||||
|
||||
const sampleIndexIdentifierTag = sampleIndexEvent.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "d"
|
||||
);
|
||||
assert(sampleIndexIdentifierTag, "index event should include identifier tag");
|
||||
assert.equal(
|
||||
sampleIndexIdentifierTag[1],
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
"index event should use canonical watch history identifier"
|
||||
);
|
||||
|
||||
const sampleChunksTag = sampleIndexEvent.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "chunks"
|
||||
);
|
||||
assert(sampleChunksTag, "index event should include chunks tag");
|
||||
assert.equal(
|
||||
Number.parseInt(sampleChunksTag[1], 10),
|
||||
2,
|
||||
"chunks tag should reflect total chunks"
|
||||
);
|
||||
|
||||
const sampleIndexAddresses = sampleIndexEvent.tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
|
||||
.map((tag) => tag[1]);
|
||||
assert.deepEqual(
|
||||
sampleIndexAddresses,
|
||||
sampleChunkAddresses,
|
||||
"index event should list chunk addresses"
|
||||
);
|
||||
|
||||
const sampleChunkEvent = buildWatchHistoryChunkEvent({
|
||||
pubkey: ACTOR,
|
||||
created_at: 222,
|
||||
chunkIdentifier: sampleChunkIdentifier,
|
||||
snapshotId: "sample",
|
||||
chunkIndex: 0,
|
||||
totalChunks: 2,
|
||||
pointerTags: [["e", "sample-event"]],
|
||||
content: "ciphertext",
|
||||
});
|
||||
|
||||
const chunkIdentifierTag = sampleChunkEvent.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "d"
|
||||
);
|
||||
assert(chunkIdentifierTag, "chunk event should include identifier tag");
|
||||
assert.equal(
|
||||
chunkIdentifierTag[1],
|
||||
sampleChunkIdentifier,
|
||||
"chunk event should use provided identifier"
|
||||
);
|
||||
assert(
|
||||
sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "snapshot"),
|
||||
"chunk event should include snapshot tag"
|
||||
);
|
||||
assert(
|
||||
sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk"),
|
||||
"chunk event should include chunk tag"
|
||||
);
|
||||
assert(
|
||||
sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "encrypted"),
|
||||
"chunk event should include encrypted tag"
|
||||
);
|
||||
|
||||
setNostrEventSchemaOverrides({
|
||||
[NOTE_TYPES.WATCH_HISTORY_INDEX]: {
|
||||
identifierTag: { name: "d", value: "custom:index" },
|
||||
},
|
||||
[NOTE_TYPES.WATCH_HISTORY_CHUNK]: {
|
||||
identifierTag: { name: "d", value: "custom:chunk" },
|
||||
},
|
||||
});
|
||||
|
||||
const overriddenIndexEvent = buildWatchHistoryIndexEvent({
|
||||
pubkey: ACTOR,
|
||||
created_at: 333,
|
||||
snapshotId: "override",
|
||||
totalChunks: 1,
|
||||
});
|
||||
assert(
|
||||
overriddenIndexEvent.tags.some(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:index"
|
||||
),
|
||||
"index builder should honor overrides"
|
||||
);
|
||||
|
||||
const overriddenChunkEvent = buildWatchHistoryChunkEvent({
|
||||
pubkey: ACTOR,
|
||||
created_at: 444,
|
||||
snapshotId: "override",
|
||||
chunkIndex: 0,
|
||||
totalChunks: 1,
|
||||
pointerTags: [],
|
||||
content: "cipher",
|
||||
});
|
||||
assert(
|
||||
overriddenChunkEvent.tags.some(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:chunk"
|
||||
),
|
||||
"chunk builder should honor identifier override"
|
||||
);
|
||||
|
||||
setNostrEventSchemaOverrides({});
|
||||
|
||||
const { client: publishingClient, publishedEvents, payloads } =
|
||||
createPublishingClient(ACTOR);
|
||||
@@ -99,11 +232,11 @@ const publishResult = await publishingClient.publishWatchHistorySnapshot(
|
||||
);
|
||||
|
||||
assert.equal(publishResult.ok, true, "publish should report success");
|
||||
assert(publishResult.events.length > 1, "snapshot should chunk into multiple events");
|
||||
assert(publishResult.events.length > 1, "snapshot should include index and chunk events");
|
||||
assert.equal(
|
||||
publishResult.events.length,
|
||||
payloads.length,
|
||||
"each chunk should produce an encryption payload"
|
||||
payloads.length + 1,
|
||||
"index event should accompany each chunk payload"
|
||||
);
|
||||
|
||||
payloads.forEach((payload) => {
|
||||
@@ -120,53 +253,67 @@ const snapshotIds = new Set(
|
||||
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1]
|
||||
)
|
||||
);
|
||||
assert.equal(snapshotIds.size, 1, "all chunks should share a snapshot id");
|
||||
assert.equal(snapshotIds.size, 1, "all events should share a snapshot id");
|
||||
|
||||
const headTag = publishResult.event.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "head"
|
||||
const indexEvent = publishResult.indexEvent;
|
||||
assert(indexEvent, "publish result should include index event");
|
||||
|
||||
const indexIdentifierTag = indexEvent.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "d"
|
||||
);
|
||||
assert(indexIdentifierTag, "index event should include identifier tag");
|
||||
assert.equal(
|
||||
indexIdentifierTag[1],
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
"index event should use canonical identifier"
|
||||
);
|
||||
assert(headTag, "head chunk should include head tag");
|
||||
|
||||
const chunkIdentifiers = publishResult.events.map((event) =>
|
||||
const indexChunksTag = indexEvent.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "chunks"
|
||||
);
|
||||
assert(indexChunksTag, "index event should include chunks tag");
|
||||
assert.equal(
|
||||
Number.parseInt(indexChunksTag[1], 10),
|
||||
publishResult.chunks.length,
|
||||
"index event should report chunk count"
|
||||
);
|
||||
|
||||
const chunkEvents = publishResult.chunks.map((chunk) => chunk.event);
|
||||
const chunkIdentifiers = chunkEvents.map((event) =>
|
||||
event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1]
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
chunkIdentifiers.length,
|
||||
publishResult.events.length,
|
||||
chunkEvents.length,
|
||||
"each chunk should expose a d tag"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
chunkIdentifiers[0],
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
"head chunk should retain canonical watch history identifier"
|
||||
);
|
||||
|
||||
const uniqueChunkIdentifiers = new Set(chunkIdentifiers);
|
||||
assert.equal(
|
||||
uniqueChunkIdentifiers.size,
|
||||
publishResult.events.length,
|
||||
chunkEvents.length,
|
||||
"each chunk should have a unique d tag"
|
||||
);
|
||||
|
||||
chunkIdentifiers.slice(1).forEach((identifier, index) => {
|
||||
chunkIdentifiers.forEach((identifier, index) => {
|
||||
assert(identifier, `chunk ${index} should include identifier`);
|
||||
assert(
|
||||
identifier && identifier.startsWith("watch-history:"),
|
||||
`chunk ${index + 1} should use a watch-history namespace identifier`
|
||||
identifier.startsWith(WATCH_HISTORY_CHUNK_PREFIX),
|
||||
`chunk ${index} should use the chunk prefix`
|
||||
);
|
||||
assert.notEqual(
|
||||
identifier,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
"only the head chunk should use the canonical identifier"
|
||||
"chunk identifiers should differ from index identifier"
|
||||
);
|
||||
});
|
||||
|
||||
const headATags = publishResult.event.tags
|
||||
const indexAddresses = indexEvent.tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
|
||||
.map((tag) => tag[1]);
|
||||
|
||||
const expectedChunkAddresses = publishResult.events.map((event) => {
|
||||
const expectedChunkAddresses = chunkEvents.map((event) => {
|
||||
const identifier = event.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "d"
|
||||
)?.[1];
|
||||
@@ -176,8 +323,8 @@ const expectedChunkAddresses = publishResult.events.map((event) => {
|
||||
|
||||
expectedChunkAddresses.forEach((address) => {
|
||||
assert(
|
||||
headATags.includes(address),
|
||||
`head chunk should reference ${address}`
|
||||
indexAddresses.includes(address),
|
||||
`index event should reference ${address}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -229,9 +376,6 @@ if (typeof localStorage !== "undefined") {
|
||||
}
|
||||
|
||||
const headEvent = publishResult.event;
|
||||
const chunkEvents = publishResult.events.filter(
|
||||
(event) => event.id !== headEvent.id
|
||||
);
|
||||
|
||||
const snapshotId = headEvent.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "snapshot"
|
||||
|
||||
Reference in new issue
Block a user