Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/repositories/event-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <T1, T2>(onfulfilled: (value: number) => T1 | PromiseLike<T1>, onrejected: (reason: any) => T2 | PromiseLike<T2>) => query.then(prop('rowCount') as () => number).then(onfulfilled, onrejected),
Expand Down
6 changes: 6 additions & 0 deletions test/integration/features/nip-16/nip-16.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 60 additions & 17 deletions test/integration/features/nip-16/nip-16.feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand All @@ -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,
Expand All @@ -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,
) {
Expand All @@ -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)
})

4 changes: 2 additions & 2 deletions test/unit/repositories/event-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\'))')
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do any of these test cases cover the tie-breaker or this just updates existing queries?
Let's make sure we have a test case for the tie-breaker per the spec.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right! Those updates in event-repository.spec.ts are strictly unit "snapshot" tests to ensure the new nested Knex logic generates correctly formatted SQL syntax under the hood.

I completely agree that an actual behavioral integration test is crucial here. I will add a dedicated scenario to the Cucumber test suite test/integration/features/nip-16/nip-16.feature strictly verifying the NIP-01 lexical ID tie-breaker is respected during identical timestamp collisions. I’ll push that commit up shortly!

})

it('replaces event based on event_pubkey, event_kind and event_deduplication', () => {
Expand All @@ -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\'))')
})
})
})
Loading