From 6fc7788a4fb9b4df6285160096a36b251d7db4dd Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 1 Feb 2026 08:44:49 -0300 Subject: [PATCH] utils: merging two (reverse) sorted lists of events. --- utils.test.ts | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++ utils.ts | 62 +++++++++++++++++++++++++++++++++-- 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/utils.test.ts b/utils.test.ts index 4f11546..5361829 100644 --- a/utils.test.ts +++ b/utils.test.ts @@ -6,6 +6,7 @@ import { insertEventIntoDescendingList, binarySearch, normalizeURL, + mergeReverseSortedLists, } from './utils.ts' import type { Event } from './core.ts' @@ -270,6 +271,94 @@ test('binary search', () => { expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false]) }) +describe('mergeReverseSortedLists', () => { + test('merge empty lists', () => { + const list1: Event[] = [] + const list2: Event[] = [] + expect(mergeReverseSortedLists(list1, list2)).toHaveLength(0) + }) + + test('merge list with empty list', () => { + const list1 = [buildEvent({ id: 'a', created_at: 30 }), buildEvent({ id: 'b', created_at: 20 })] + const list2: Event[] = [] + const result = mergeReverseSortedLists(list1, list2) + expect(result).toHaveLength(2) + expect(result.map(e => e.id)).toEqual(['a', 'b']) + }) + + test('merge two simple lists', () => { + const list1 = [ + buildEvent({ id: 'a', created_at: 30 }), + buildEvent({ id: 'b', created_at: 10 }), + buildEvent({ id: 'f', created_at: 3 }), + buildEvent({ id: 'g', created_at: 2 }), + ] + const list2 = [ + buildEvent({ id: 'c', created_at: 25 }), + buildEvent({ id: 'd', created_at: 5 }), + buildEvent({ id: 'e', created_at: 1 }), + ] + const result = mergeReverseSortedLists(list1, list2) + expect(result.map(e => e.id)).toEqual(['a', 'c', 'b', 'd', 'f', 'g', 'e']) + }) + + test('merge lists with same timestamps', () => { + const list1 = [ + buildEvent({ id: 'a', created_at: 30 }), + buildEvent({ id: 'b', created_at: 20 }), + buildEvent({ id: 'f', created_at: 10 }), + ] + const list2 = [ + buildEvent({ id: 'c', created_at: 30 }), + buildEvent({ id: 'd', created_at: 20 }), + buildEvent({ id: 'e', created_at: 20 }), + ] + const result = mergeReverseSortedLists(list1, list2) + expect(result.map(e => e.id)).toEqual(['c', 'a', 'd', 'e', 'b', 'f']) + }) + + test('deduplicate events with same timestamp and id', () => { + const list1 = [ + buildEvent({ id: 'a', created_at: 30 }), + buildEvent({ id: 'b', created_at: 20 }), + buildEvent({ id: 'b', created_at: 20 }), + buildEvent({ id: 'c', created_at: 20 }), + buildEvent({ id: 'd', created_at: 10 }), + ] + const list2 = [ + buildEvent({ id: 'a', created_at: 30 }), + buildEvent({ id: 'c', created_at: 20 }), + buildEvent({ id: 'b', created_at: 20 }), + buildEvent({ id: 'd', created_at: 10 }), + buildEvent({ id: 'e', created_at: 10 }), + buildEvent({ id: 'd', created_at: 10 }), + ] + console.log('==================') + const result = mergeReverseSortedLists(list1, list2) + console.log( + 'result:', + result.map(e => e.id), + ) + expect(result.map(e => e.id)).toEqual(['a', 'c', 'b', 'd', 'e']) + }) + + test('merge when one list is completely before the other', () => { + const list1 = [buildEvent({ id: 'a', created_at: 50 }), buildEvent({ id: 'b', created_at: 40 })] + const list2 = [buildEvent({ id: 'c', created_at: 30 }), buildEvent({ id: 'd', created_at: 20 })] + const result = mergeReverseSortedLists(list1, list2) + expect(result).toHaveLength(4) + expect(result.map(e => e.id)).toEqual(['a', 'b', 'c', 'd']) + }) + + test('merge when one list is completely after the other', () => { + const list1 = [buildEvent({ id: 'a', created_at: 10 }), buildEvent({ id: 'b', created_at: 5 })] + const list2 = [buildEvent({ id: 'c', created_at: 30 }), buildEvent({ id: 'd', created_at: 20 })] + const result = mergeReverseSortedLists(list1, list2) + expect(result).toHaveLength(4) + expect(result.map(e => e.id)).toEqual(['c', 'd', 'a', 'b']) + }) +}) + describe('normalizeURL', () => { test('normalizes wss:// URLs', () => { expect(normalizeURL('wss://example.com')).toBe('wss://example.com/') diff --git a/utils.ts b/utils.ts index 55d6a32..5e28787 100644 --- a/utils.ts +++ b/utils.ts @@ -1,4 +1,4 @@ -import type { Event } from './core.ts' +import type { NostrEvent } from './core.ts' export const utf8Decoder: TextDecoder = new TextDecoder('utf-8') export const utf8Encoder: TextEncoder = new TextEncoder() @@ -22,7 +22,7 @@ export function normalizeURL(url: string): string { } } -export function insertEventIntoDescendingList(sortedArray: Event[], event: Event): Event[] { +export function insertEventIntoDescendingList(sortedArray: NostrEvent[], event: NostrEvent): NostrEvent[] { const [idx, found] = binarySearch(sortedArray, b => { if (event.id === b.id) return 0 if (event.created_at === b.created_at) return -1 @@ -34,7 +34,7 @@ export function insertEventIntoDescendingList(sortedArray: Event[], event: Event return sortedArray } -export function insertEventIntoAscendingList(sortedArray: Event[], event: Event): Event[] { +export function insertEventIntoAscendingList(sortedArray: NostrEvent[], event: NostrEvent): NostrEvent[] { const [idx, found] = binarySearch(sortedArray, b => { if (event.id === b.id) return 0 if (event.created_at === b.created_at) return -1 @@ -68,6 +68,62 @@ export function binarySearch(arr: T[], compare: (b: T) => number): [number, b return [start, false] } +export function mergeReverseSortedLists(list1: NostrEvent[], list2: NostrEvent[]): NostrEvent[] { + const result: NostrEvent[] = new Array(list1.length + list2.length) + result.length = 0 + let i1 = 0 + let i2 = 0 + let sameTimestampIds: string[] = [] + + while (i1 < list1.length && i2 < list2.length) { + let next: NostrEvent + if (list1[i1]?.created_at > list2[i2]?.created_at) { + next = list1[i1] + i1++ + } else { + next = list2[i2] + i2++ + } + + if (result.length > 0 && result[result.length - 1].created_at === next.created_at) { + if (sameTimestampIds.includes(next.id)) continue + } else { + sameTimestampIds.length = 0 + } + + result.push(next) + sameTimestampIds.push(next.id) + } + + while (i1 < list1.length) { + const next = list1[i1] + i1++ + + if (result.length > 0 && result[result.length - 1].created_at === next.created_at) { + if (sameTimestampIds.includes(next.id)) continue + } else { + sameTimestampIds.length = 0 + } + result.push(next) + sameTimestampIds.push(next.id) + } + + while (i2 < list2.length) { + const next = list2[i2] + i2++ + + if (result.length > 0 && result[result.length - 1].created_at === next.created_at) { + if (sameTimestampIds.includes(next.id)) continue + } else { + sameTimestampIds.length = 0 + } + result.push(next) + sameTimestampIds.push(next.id) + } + + return result +} + export class QueueNode { public value: V public next: QueueNode | null = null