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(); 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()]; }