74 lines
2.6 KiB
TypeScript
74 lines
2.6 KiB
TypeScript
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
/**
|
|
* Deterministic event merging.
|
|
*
|
|
* Nostr does not provide global ordering: relays can return the same logical
|
|
* event set in a different order, and two relays can serve different versions
|
|
* of an addressable event. We do not attempt consensus; we make the client's
|
|
* local answer deterministic and explainable.
|
|
*
|
|
* Rules:
|
|
* - addressable events (`d` tag): newest `created_at` wins;
|
|
* - replaceable events (kind 0/3 and 10000-19999): newest `created_at` wins;
|
|
* - regular events: identity is the event id;
|
|
* - equal timestamps: lexicographically larger event id wins, so relay order
|
|
* and Promise timing cannot change the result.
|
|
*
|
|
* Local NIP-09 tombstones are applied by the caller before/while merging; a
|
|
* tombstone is a device-local safety net, not a claim that every relay erased
|
|
* the event.
|
|
*/
|
|
export type EventMergePredicate = (event: NostrEvent) => boolean;
|
|
|
|
export interface EventMergeOptions {
|
|
/** Return true for events that must not appear (local tombstones, moderation). */
|
|
isDeleted?: EventMergePredicate;
|
|
}
|
|
|
|
/** Stable identity used to compare two versions of the same logical event. */
|
|
export function eventMergeKey(event: NostrEvent): string {
|
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
|
if (dTag !== undefined) return `${event.pubkey}:${event.kind}:${dTag}`;
|
|
if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
|
|
return `${event.pubkey}:${event.kind}`;
|
|
}
|
|
return event.id;
|
|
}
|
|
|
|
/**
|
|
* Return true when `candidate` should replace `current` for the same key.
|
|
* Larger ids win ties; this is arbitrary but stable across relays and devices.
|
|
*/
|
|
export function eventReplaces(current: NostrEvent, candidate: NostrEvent): boolean {
|
|
if (candidate.created_at !== current.created_at) {
|
|
return candidate.created_at > current.created_at;
|
|
}
|
|
return candidate.id > current.id;
|
|
}
|
|
|
|
/**
|
|
* Merge events from one or more relays into one deterministic list.
|
|
*
|
|
* The returned list is sorted by key only for stable snapshots; callers that
|
|
* care about display order should sort for their own UI.
|
|
*/
|
|
export function mergeEvents(
|
|
events: readonly NostrEvent[],
|
|
options: EventMergeOptions = {},
|
|
): NostrEvent[] {
|
|
const { isDeleted } = options;
|
|
const byKey = new Map<string, NostrEvent>();
|
|
|
|
for (const event of events) {
|
|
if (isDeleted?.(event)) continue;
|
|
|
|
const key = eventMergeKey(event);
|
|
const current = byKey.get(key);
|
|
if (!current || eventReplaces(current, event)) {
|
|
byKey.set(key, event);
|
|
}
|
|
}
|
|
|
|
return [...byKey.values()];
|
|
}
|