From fd092f0825c164ceb25c11a1c65d2e03132caa0e Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Apr 2026 15:33:00 +0530 Subject: [PATCH 1/2] fix: add tie-breaker to upsertMany --- src/repositories/event-repository.ts | 27 +++++++++----- .../repositories/event-repository.spec.ts | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index dba91177..5d01cc04 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -174,16 +174,18 @@ export class EventRepository implements IEventRepository { public async createMany(events: Event[]): Promise { if (!events.length) { - return 0 - } - - const rows = events.map((event) => this.toInsertRow(event)) - - return this.masterDbClient('events') - .insert(rows) - .onConflict() - .ignore() - .then(prop('rowCount') as () => number, () => 0) + .merge([ + 'deleted_at', + 'event_content', + 'event_created_at', + 'event_id', + 'event_signature', + 'event_tags', + 'expires_at', + ]) + .whereRaw( + '("events"."event_created_at" < "excluded"."event_created_at" or ("events"."event_created_at" = "excluded"."event_created_at" and "events"."event_id" > "excluded"."event_id"))', + ) } private toInsertRow(event: Event) { @@ -260,6 +262,7 @@ export class EventRepository implements IEventRepository { '(event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000)', ), ) +<<<<<<< HEAD .merge([ 'deleted_at', 'event_content', @@ -270,6 +273,10 @@ export class EventRepository implements IEventRepository { 'expires_at', ]) .whereRaw('"events"."event_created_at" < "excluded"."event_created_at"') +======= + .merge(['deleted_at', 'event_content', 'event_created_at', 'event_id', 'event_signature', 'event_tags', 'expires_at']) + .whereRaw('("events"."event_created_at" < "excluded"."event_created_at" or ("events"."event_created_at" = "excluded"."event_created_at" and "events"."event_id" > "excluded"."event_id"))') +>>>>>>> 03e4d60 (fix: add tie-breaker to upsertMany) .then(prop('rowCount') as () => number, () => 0) } diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index f2c71cb2..d3fc1d34 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -538,4 +538,41 @@ describe('EventRepository', () => { ) }) }) + + describe('upsertMany', () => { + it('returns 0 when no events are provided', async () => { + const result = await repository.upsertMany([]) + + expect(result).to.equal(0) + }) + + it('applies NIP-01 tie-breaker in batch conflict condition', async () => { + const thenStub = sandbox.stub().callsFake((onfulfilled) => Promise.resolve(onfulfilled({ rowCount: 1 }))) + const whereRawStub = sandbox.stub().returns({ then: thenStub }) + const mergeStub = sandbox.stub().returns({ whereRaw: whereRawStub }) + const onConflictStub = sandbox.stub().returns({ merge: mergeStub }) + const insertStub = sandbox.stub().returns({ onConflict: onConflictStub }) + const masterDbClientStub = sandbox.stub().returns({ insert: insertStub }) as unknown as DatabaseClient + + ;(masterDbClientStub as any).raw = sandbox.stub().returns('conflict-target') + + repository = new EventRepository(masterDbClientStub, rrDbClient) + + const event: Event = { + id: 'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a', + pubkey: '55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503', + created_at: 1564498626, + kind: 0, + tags: [], + content: '{"name":"ottman@minds.io"}', + sig: 'd1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9', + [ContextMetadataKey]: { remoteAddress: { address: '::1' } as any }, + } + + const result = await repository.upsertMany([event]) + + expect(whereRawStub).to.have.been.calledOnceWithExactly('("events"."event_created_at" < "excluded"."event_created_at" or ("events"."event_created_at" = "excluded"."event_created_at" and "events"."event_id" > "excluded"."event_id"))') + expect(result).to.equal(1) + }) + }) }) From 57d940615c4291d187dc177eeb9c6ab149af684c Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Apr 2026 16:31:37 +0530 Subject: [PATCH 2/2] fix: resolve merge artifacts and add changeset --- .changeset/fix-upsertmany-tiebreak.md | 5 +++++ src/repositories/event-repository.ts | 31 +++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-upsertmany-tiebreak.md diff --git a/.changeset/fix-upsertmany-tiebreak.md b/.changeset/fix-upsertmany-tiebreak.md new file mode 100644 index 00000000..47f86a82 --- /dev/null +++ b/.changeset/fix-upsertmany-tiebreak.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Fix replaceable batch upserts to apply NIP-01 tie-breaker semantics when timestamps are equal by comparing event IDs. diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index 5d01cc04..56204d1f 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -174,18 +174,16 @@ export class EventRepository implements IEventRepository { public async createMany(events: Event[]): Promise { if (!events.length) { - .merge([ - 'deleted_at', - 'event_content', - 'event_created_at', - 'event_id', - 'event_signature', - 'event_tags', - 'expires_at', - ]) - .whereRaw( - '("events"."event_created_at" < "excluded"."event_created_at" or ("events"."event_created_at" = "excluded"."event_created_at" and "events"."event_id" > "excluded"."event_id"))', - ) + return 0 + } + + const rows = events.map((event) => this.toInsertRow(event)) + + return this.masterDbClient('events') + .insert(rows) + .onConflict() + .ignore() + .then(prop('rowCount') as () => number, () => 0) } private toInsertRow(event: Event) { @@ -262,7 +260,6 @@ export class EventRepository implements IEventRepository { '(event_pubkey, event_kind, event_deduplication) WHERE (event_kind = 0 OR event_kind = 3 OR event_kind = 41 OR (event_kind >= 10000 AND event_kind < 20000)) OR (event_kind >= 30000 AND event_kind < 40000)', ), ) -<<<<<<< HEAD .merge([ 'deleted_at', 'event_content', @@ -272,11 +269,9 @@ export class EventRepository implements IEventRepository { 'event_tags', 'expires_at', ]) - .whereRaw('"events"."event_created_at" < "excluded"."event_created_at"') -======= - .merge(['deleted_at', 'event_content', 'event_created_at', 'event_id', 'event_signature', 'event_tags', 'expires_at']) - .whereRaw('("events"."event_created_at" < "excluded"."event_created_at" or ("events"."event_created_at" = "excluded"."event_created_at" and "events"."event_id" > "excluded"."event_id"))') ->>>>>>> 03e4d60 (fix: add tie-breaker to upsertMany) + .whereRaw( + '("events"."event_created_at" < "excluded"."event_created_at" or ("events"."event_created_at" = "excluded"."event_created_at" and "events"."event_id" > "excluded"."event_id"))', + ) .then(prop('rowCount') as () => number, () => 0) }