Skip to content

Commit 12688ec

Browse files
authored
Fix: nip01 replaceable tiebreaker (#416)
* fix: update event upsert query to handle duplicate timestamps using event ID comparison * test: add NIP-01 tie-breaker integration test for identical timestamps
1 parent b172235 commit 12688ec

4 files changed

Lines changed: 75 additions & 20 deletions

File tree

src/repositories/event-repository.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,13 @@ export class EventRepository implements IEventRepository {
227227
)
228228
)
229229
.merge(omit(['event_pubkey', 'event_kind', 'event_deduplication'])(row))
230-
.where('events.event_created_at', '<', row.event_created_at)
230+
.where(function () {
231+
this.where('events.event_created_at', '<', row.event_created_at)
232+
.orWhere(function () {
233+
this.where('events.event_created_at', '=', row.event_created_at)
234+
.andWhere('events.event_id', '>', row.event_id)
235+
})
236+
})
231237

232238
return {
233239
then: <T1, T2>(onfulfilled: (value: number) => T1 | PromiseLike<T1>, onrejected: (reason: any) => T2 | PromiseLike<T2>) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected),

test/integration/features/nip-16/nip-16.feature

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Feature: NIP-16 Event treatment
99
When Alice subscribes to author Alice
1010
Then Alice receives 1 replaceable_event_0 event from Alice with content "updated" and EOSE
1111

12+
Scenario: Tie-breaker on Identical Timestamps
13+
Given someone called Alice
14+
When Alice sends two identically-timestamped replaceable_event_0 events where the second has a lower ID
15+
And Alice subscribes to author Alice
16+
Then Alice receives 1 replaceable_event_0 event from Alice matching the lower ID event and EOSE
17+
1218
Scenario: Charlie sends an ephemeral event
1319
Given someone called Charlie
1420
Given someone called Alice

test/integration/features/nip-16/nip-16.feature.ts

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import WebSocket from 'ws'
55
import { createEvent, sendEvent, waitForEventCount, waitForNextEvent } from '../helpers'
66
import { Event } from '../../../../src/@types/event'
77

8-
When(/^(\w+) sends a replaceable_event_0 event with content "([^"]+)"$/, async function(
8+
When(/^(\w+) sends a replaceable_event_0 event with content "([^"]+)"$/, async function (
99
name: string,
1010
content: string,
1111
) {
@@ -20,17 +20,17 @@ When(/^(\w+) sends a replaceable_event_0 event with content "([^"]+)"$/, async f
2020

2121
Then(
2222
/(\w+) receives a replaceable_event_0 event from (\w+) with content "([^"]+?)"/,
23-
async function(name: string, author: string, content: string) {
24-
const ws = this.parameters.clients[name] as WebSocket
25-
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
26-
const receivedEvent = await waitForNextEvent(ws, subscription.name)
23+
async function (name: string, author: string, content: string) {
24+
const ws = this.parameters.clients[name] as WebSocket
25+
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
26+
const receivedEvent = await waitForNextEvent(ws, subscription.name)
2727

28-
expect(receivedEvent.kind).to.equal(10000)
29-
expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey)
30-
expect(receivedEvent.content).to.equal(content)
31-
})
28+
expect(receivedEvent.kind).to.equal(10000)
29+
expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey)
30+
expect(receivedEvent.content).to.equal(content)
31+
})
3232

33-
Then(/(\w+) receives (\d+) replaceable_event_0 events? from (\w+) with content "([^"]+?)" and EOSE/, async function(
33+
Then(/(\w+) receives (\d+) replaceable_event_0 events? from (\w+) with content "([^"]+?)" and EOSE/, async function (
3434
name: string,
3535
count: string,
3636
author: string,
@@ -46,7 +46,7 @@ Then(/(\w+) receives (\d+) replaceable_event_0 events? from (\w+) with content "
4646
expect(events[0].content).to.equal(content)
4747
})
4848

49-
When(/^(\w+) sends a ephemeral_event_0 event with content "([^"]+)"$/, async function(
49+
When(/^(\w+) sends a ephemeral_event_0 event with content "([^"]+)"$/, async function (
5050
name: string,
5151
content: string,
5252
) {
@@ -61,23 +61,66 @@ When(/^(\w+) sends a ephemeral_event_0 event with content "([^"]+)"$/, async fun
6161

6262
Then(
6363
/(\w+) receives a ephemeral_event_0 event from (\w+) with content "([^"]+?)"/,
64-
async function(name: string, author: string, content: string) {
64+
async function (name: string, author: string, content: string) {
65+
const ws = this.parameters.clients[name] as WebSocket
66+
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
67+
const receivedEvent = await waitForNextEvent(ws, subscription.name)
68+
69+
expect(receivedEvent.kind).to.equal(20000)
70+
expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey)
71+
expect(receivedEvent.content).to.equal(content)
72+
})
73+
74+
Then(/(\w+) receives (\d+) ephemeral_event_0 events? and EOSE/, async function (
75+
name: string,
76+
count: string,
77+
) {
6578
const ws = this.parameters.clients[name] as WebSocket
6679
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
67-
const receivedEvent = await waitForNextEvent(ws, subscription.name)
80+
const events = await waitForEventCount(ws, subscription.name, Number(count), true)
6881

69-
expect(receivedEvent.kind).to.equal(20000)
70-
expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey)
71-
expect(receivedEvent.content).to.equal(content)
82+
expect(events.length).to.equal(Number(count))
7283
})
7384

74-
Then(/(\w+) receives (\d+) ephemeral_event_0 events? and EOSE/, async function(
85+
When(/^(\w+) sends two identically-timestamped replaceable_event_0 events where the second has a lower ID$/, async function (
86+
name: string
87+
) {
88+
const ws = this.parameters.clients[name] as WebSocket
89+
const { pubkey, privkey } = this.parameters.identities[name]
90+
91+
const commonTimestamp = Math.floor(Date.now() / 1000)
92+
93+
const event1 = await createEvent({ pubkey, kind: 10000, content: 'first content', created_at: commonTimestamp }, privkey)
94+
95+
let nonce = 0
96+
let event2: Event
97+
for (; ;) {
98+
event2 = await createEvent({ pubkey, kind: 10000, content: `second content ${nonce++}`, created_at: commonTimestamp }, privkey)
99+
100+
if (event2.id < event1.id) {
101+
break
102+
}
103+
}
104+
105+
await sendEvent(ws, event1)
106+
await sendEvent(ws, event2)
107+
108+
this.parameters.events[name].push(event1, event2)
109+
this.parameters.lowerIdEventContent = event2.content
110+
})
111+
112+
Then(/(\w+) receives (\d+) replaceable_event_0 event from (\w+) matching the lower ID event and EOSE/, async function (
75113
name: string,
76114
count: string,
115+
author: string,
77116
) {
78117
const ws = this.parameters.clients[name] as WebSocket
79118
const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1]
80119
const events = await waitForEventCount(ws, subscription.name, Number(count), true)
81120

82121
expect(events.length).to.equal(Number(count))
122+
expect(events[0].kind).to.equal(10000)
123+
expect(events[0].pubkey).to.equal(this.parameters.identities[author].pubkey)
124+
expect(events[0].content).to.equal(this.parameters.lowerIdEventContent)
83125
})
126+

test/unit/repositories/event-repository.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ describe('EventRepository', () => {
504504

505505
const query = repository.upsert(event).toString()
506506

507-
expect(query).to.equal('insert into "events" ("deleted_at", "event_content", "event_created_at", "event_deduplication", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "expires_at", "remote_address") values (NULL, \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503",0]\', X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', NULL, \'::1\') on conflict (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) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"remote_address" = \'::1\',"expires_at" = NULL,"deleted_at" = NULL where "events"."event_created_at" < 1564498626')
507+
expect(query).to.equal('insert into "events" ("deleted_at", "event_content", "event_created_at", "event_deduplication", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "expires_at", "remote_address") values (NULL, \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503",0]\', X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', NULL, \'::1\') on conflict (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) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"remote_address" = \'::1\',"expires_at" = NULL,"deleted_at" = NULL where ("events"."event_created_at" < 1564498626 or ("events"."event_created_at" = 1564498626 and "events"."event_id" > X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\'))')
508508
})
509509

510510
it('replaces event based on event_pubkey, event_kind and event_deduplication', () => {
@@ -522,7 +522,7 @@ describe('EventRepository', () => {
522522

523523
const query = repository.upsert(event).toString()
524524

525-
expect(query).to.equal('insert into "events" ("deleted_at", "event_content", "event_created_at", "event_deduplication", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "expires_at", "remote_address") values (NULL, \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["deduplication"]\', X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', NULL, \'::1\') on conflict (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) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"remote_address" = \'::1\',"expires_at" = NULL,"deleted_at" = NULL where "events"."event_created_at" < 1564498626')
525+
expect(query).to.equal('insert into "events" ("deleted_at", "event_content", "event_created_at", "event_deduplication", "event_id", "event_kind", "event_pubkey", "event_signature", "event_tags", "expires_at", "remote_address") values (NULL, \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\', 1564498626, \'["deduplication"]\', X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\', 0, X\'55b702c167c85eb1c2d5ab35d68bedd1a35b94c01147364d2395c2f66f35a503\', X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\', \'[]\', NULL, \'::1\') on conflict (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) do update set "event_id" = X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\',"event_created_at" = 1564498626,"event_tags" = \'[]\',"event_content" = \'{"name":"ottman@minds.io","about":"","picture":"https://feat-2311-nostr.minds.io/icon/1002952989368913934/medium/1564498626/1564498626/1653379539"}\',"event_signature" = X\'d1de98733de2b412549aa64454722d9b66ab3c68e9e0d0f9c5d42e7bd54c30a06174364b683d2c8dbb386ff47f31e6cb7e2f3c3498d8819ee80421216c8309a9\',"remote_address" = \'::1\',"expires_at" = NULL,"deleted_at" = NULL where ("events"."event_created_at" < 1564498626 or ("events"."event_created_at" = 1564498626 and "events"."event_id" > X\'e527fe8b0f64a38c6877f943a9e8841074056ba72aceb31a4c85e6d10b27095a\'))')
526526
})
527527
})
528528
})

0 commit comments

Comments
 (0)