SkateSpots/src/lib/eventMerge.ts

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