diff --git a/src/repositories/event-repository.ts b/src/repositories/event-repository.ts index c6950c8e..021f9955 100644 --- a/src/repositories/event-repository.ts +++ b/src/repositories/event-repository.ts @@ -227,7 +227,13 @@ export class EventRepository implements IEventRepository { ) ) .merge(omit(['event_pubkey', 'event_kind', 'event_deduplication'])(row)) - .where('events.event_created_at', '<', row.event_created_at) + .where(function () { + this.where('events.event_created_at', '<', row.event_created_at) + .orWhere(function () { + this.where('events.event_created_at', '=', row.event_created_at) + .andWhere('events.event_id', '>', row.event_id) + }) + }) return { then: (onfulfilled: (value: number) => T1 | PromiseLike, onrejected: (reason: any) => T2 | PromiseLike) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected), diff --git a/test/integration/features/nip-16/nip-16.feature b/test/integration/features/nip-16/nip-16.feature index b99d5f55..57cba6a1 100644 --- a/test/integration/features/nip-16/nip-16.feature +++ b/test/integration/features/nip-16/nip-16.feature @@ -9,6 +9,12 @@ Feature: NIP-16 Event treatment When Alice subscribes to author Alice Then Alice receives 1 replaceable_event_0 event from Alice with content "updated" and EOSE + Scenario: Tie-breaker on Identical Timestamps + Given someone called Alice + When Alice sends two identically-timestamped replaceable_event_0 events where the second has a lower ID + And Alice subscribes to author Alice + Then Alice receives 1 replaceable_event_0 event from Alice matching the lower ID event and EOSE + Scenario: Charlie sends an ephemeral event Given someone called Charlie Given someone called Alice diff --git a/test/integration/features/nip-16/nip-16.feature.ts b/test/integration/features/nip-16/nip-16.feature.ts index 72aa5b5d..8412bdc9 100644 --- a/test/integration/features/nip-16/nip-16.feature.ts +++ b/test/integration/features/nip-16/nip-16.feature.ts @@ -5,7 +5,7 @@ import WebSocket from 'ws' import { createEvent, sendEvent, waitForEventCount, waitForNextEvent } from '../helpers' import { Event } from '../../../../src/@types/event' -When(/^(\w+) sends a replaceable_event_0 event with content "([^"]+)"$/, async function( +When(/^(\w+) sends a replaceable_event_0 event with content "([^"]+)"$/, async function ( name: string, content: string, ) { @@ -20,17 +20,17 @@ When(/^(\w+) sends a replaceable_event_0 event with content "([^"]+)"$/, async f Then( /(\w+) receives a replaceable_event_0 event from (\w+) with content "([^"]+?)"/, - async function(name: string, author: string, content: string) { - const ws = this.parameters.clients[name] as WebSocket - const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] - const receivedEvent = await waitForNextEvent(ws, subscription.name) + async function (name: string, author: string, content: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) - expect(receivedEvent.kind).to.equal(10000) - expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey) - expect(receivedEvent.content).to.equal(content) -}) + expect(receivedEvent.kind).to.equal(10000) + expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey) + expect(receivedEvent.content).to.equal(content) + }) -Then(/(\w+) receives (\d+) replaceable_event_0 events? from (\w+) with content "([^"]+?)" and EOSE/, async function( +Then(/(\w+) receives (\d+) replaceable_event_0 events? from (\w+) with content "([^"]+?)" and EOSE/, async function ( name: string, count: string, author: string, @@ -46,7 +46,7 @@ Then(/(\w+) receives (\d+) replaceable_event_0 events? from (\w+) with content " expect(events[0].content).to.equal(content) }) -When(/^(\w+) sends a ephemeral_event_0 event with content "([^"]+)"$/, async function( +When(/^(\w+) sends a ephemeral_event_0 event with content "([^"]+)"$/, async function ( name: string, content: string, ) { @@ -61,23 +61,66 @@ When(/^(\w+) sends a ephemeral_event_0 event with content "([^"]+)"$/, async fun Then( /(\w+) receives a ephemeral_event_0 event from (\w+) with content "([^"]+?)"/, - async function(name: string, author: string, content: string) { + async function (name: string, author: string, content: string) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const receivedEvent = await waitForNextEvent(ws, subscription.name) + + expect(receivedEvent.kind).to.equal(20000) + expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey) + expect(receivedEvent.content).to.equal(content) + }) + +Then(/(\w+) receives (\d+) ephemeral_event_0 events? and EOSE/, async function ( + name: string, + count: string, +) { const ws = this.parameters.clients[name] as WebSocket const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] - const receivedEvent = await waitForNextEvent(ws, subscription.name) + const events = await waitForEventCount(ws, subscription.name, Number(count), true) - expect(receivedEvent.kind).to.equal(20000) - expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey) - expect(receivedEvent.content).to.equal(content) + expect(events.length).to.equal(Number(count)) }) -Then(/(\w+) receives (\d+) ephemeral_event_0 events? and EOSE/, async function( +When(/^(\w+) sends two identically-timestamped replaceable_event_0 events where the second has a lower ID$/, async function ( + name: string +) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const commonTimestamp = Math.floor(Date.now() / 1000) + + const event1 = await createEvent({ pubkey, kind: 10000, content: 'first content', created_at: commonTimestamp }, privkey) + + let nonce = 0 + let event2: Event + for (; ;) { + event2 = await createEvent({ pubkey, kind: 10000, content: `second content ${nonce++}`, created_at: commonTimestamp }, privkey) + + if (event2.id < event1.id) { + break + } + } + + await sendEvent(ws, event1) + await sendEvent(ws, event2) + + this.parameters.events[name].push(event1, event2) + this.parameters.lowerIdEventContent = event2.content +}) + +Then(/(\w+) receives (\d+) replaceable_event_0 event from (\w+) matching the lower ID event and EOSE/, async function ( name: string, count: string, + author: string, ) { const ws = this.parameters.clients[name] as WebSocket const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] const events = await waitForEventCount(ws, subscription.name, Number(count), true) expect(events.length).to.equal(Number(count)) + expect(events[0].kind).to.equal(10000) + expect(events[0].pubkey).to.equal(this.parameters.identities[author].pubkey) + expect(events[0].content).to.equal(this.parameters.lowerIdEventContent) }) + diff --git a/test/unit/repositories/event-repository.spec.ts b/test/unit/repositories/event-repository.spec.ts index 9b69292e..04ceec26 100644 --- a/test/unit/repositories/event-repository.spec.ts +++ b/test/unit/repositories/event-repository.spec.ts @@ -504,7 +504,7 @@ describe('EventRepository', () => { const query = repository.upsert(event).toString() - 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') + 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\'))') }) it('replaces event based on event_pubkey, event_kind and event_deduplication', () => { @@ -522,7 +522,7 @@ describe('EventRepository', () => { const query = repository.upsert(event).toString() - 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') + 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\'))') }) }) })