Skip to content

Commit 8faa3f7

Browse files
committed
docs: mark NIP-45 as supported
1 parent fd9699b commit 8faa3f7

7 files changed

Lines changed: 121 additions & 41 deletions

File tree

.changeset/full-donuts-allow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
added NIP-45 COUNT support with end-to-end handling (validation, handler routing, DB counting, and tests).

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ NIPs with a relay-specific implementation are listed here.
6363
- [x] NIP-33: Parameterized Replaceable Events
6464
- [x] NIP-40: Expiration Timestamp
6565
- [x] NIP-44: Encrypted Payloads (Versioned)
66+
- [x] NIP-45: Event Counts
6667

6768
## Requirements
6869

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
28,
1818
33,
1919
40,
20-
44
20+
44,
21+
45
2122
],
2223
"supportedNipExtensions": [
2324
"11a"

src/handlers/count-message-handler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { IMessageHandler } from '../@types/message-handlers'
55
import { CountMessage } from '../@types/messages'
66
import { IEventRepository } from '../@types/repositories'
77
import { Settings } from '../@types/settings'
8-
import { SubscriptionFilter } from '../@types/subscription'
8+
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
99
import { WebSocketAdapterEvent } from '../constants/adapter'
1010
import { createLogger } from '../factories/logger-factory'
1111
import { createClosedMessage, createCountResultMessage } from '../utils/messages'
@@ -43,12 +43,12 @@ export class CountMessageHandler implements IMessageHandler {
4343
}
4444
}
4545

46-
private canCount(queryId: string, filters: SubscriptionFilter[]): string | undefined {
46+
private canCount(queryId: SubscriptionId, filters: SubscriptionFilter[]): string | undefined {
4747
const subscriptionLimits = this.settings().limits?.client?.subscription
4848
const maxFilters = subscriptionLimits?.maxFilters ?? 0
4949

5050
if (maxFilters > 0 && filters.length > maxFilters) {
51-
return `Too many filters: Number of filters per count query must be less then or equal to ${maxFilters}`
51+
return `Too many filters: Number of filters per count query must be less than or equal to ${maxFilters}`
5252
}
5353

5454
if (
Lines changed: 77 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chai from 'chai'
22
import EventEmitter from 'events'
3-
import sinon from 'sinon'
3+
import Sinon from 'sinon'
44
import sinonChai from 'sinon-chai'
55

66
import { IWebSocketAdapter } from '../../../src/@types/adapters'
@@ -17,18 +17,16 @@ describe('CountMessageHandler', () => {
1717
let webSocket: IWebSocketAdapter
1818
let handler: CountMessageHandler
1919
let eventRepository: IEventRepository
20-
let onMessageStub: sinon.SinonStub
21-
let sandbox: sinon.SinonSandbox
20+
let sandbox: Sinon.SinonSandbox
2221

2322
beforeEach(() => {
24-
sandbox = sinon.createSandbox()
23+
sandbox = Sinon.createSandbox()
24+
2525
eventRepository = {
2626
countByFilters: sandbox.stub().resolves(7),
2727
} as any
2828

2929
webSocket = new EventEmitter() as any
30-
onMessageStub = sandbox.stub()
31-
webSocket.on(WebSocketAdapterEvent.Message, onMessageStub)
3230

3331
handler = new CountMessageHandler(webSocket, eventRepository, () => ({
3432
limits: {
@@ -47,47 +45,89 @@ describe('CountMessageHandler', () => {
4745
sandbox.restore()
4846
})
4947

50-
it('emits COUNT message with count on success', async () => {
51-
const message = [MessageType.COUNT, 'q1', {}] as any
48+
describe('handleMessage()', () => {
49+
let webSocketOnMessageStub: Sinon.SinonStub
5250

53-
await handler.handleMessage(message)
51+
beforeEach(() => {
52+
webSocketOnMessageStub = sandbox.stub()
53+
webSocket.on(WebSocketAdapterEvent.Message, webSocketOnMessageStub)
54+
})
5455

55-
expect(eventRepository.countByFilters).to.have.been.calledOnceWithExactly([{}])
56-
expect(onMessageStub).to.have.been.calledOnceWithExactly([MessageType.COUNT, 'q1', { count: 7 }])
57-
})
56+
it('returns COUNT with the result when counting works', async () => {
57+
const message = [MessageType.COUNT, 'q1', {}] as any
5858

59-
it('emits CLOSED message when request is rejected', async () => {
60-
handler = new CountMessageHandler(webSocket, eventRepository, () => ({
61-
limits: {
62-
client: {
63-
subscription: {
64-
maxFilters: 1,
65-
maxSubscriptionIdLength: 256,
59+
await handler.handleMessage(message)
60+
61+
expect(eventRepository.countByFilters).to.have.been.calledOnceWithExactly([{}])
62+
expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly([MessageType.COUNT, 'q1', { count: 7 }])
63+
})
64+
65+
it('drops duplicate filters before querying the repository', async () => {
66+
const repeatedFilter = { kinds: [1] }
67+
const message = [MessageType.COUNT, 'q1', repeatedFilter, repeatedFilter] as any
68+
69+
await handler.handleMessage(message)
70+
71+
expect(eventRepository.countByFilters).to.have.been.calledOnceWithExactly([repeatedFilter])
72+
expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly([MessageType.COUNT, 'q1', { count: 7 }])
73+
})
74+
75+
it('returns CLOSED when the request has too many filters', async () => {
76+
handler = new CountMessageHandler(webSocket, eventRepository, () => ({
77+
limits: {
78+
client: {
79+
subscription: {
80+
maxFilters: 1,
81+
maxSubscriptionIdLength: 256,
82+
},
6683
},
6784
},
68-
},
69-
}) as Settings)
85+
}) as Settings)
86+
87+
const message = [MessageType.COUNT, 'q1', { kinds: [1] }, { kinds: [2] }] as any
88+
89+
await handler.handleMessage(message)
90+
91+
expect(eventRepository.countByFilters).to.not.have.been.called
92+
expect(webSocketOnMessageStub).to.have.been.calledOnce
93+
expect(webSocketOnMessageStub.firstCall.args[0][0]).to.equal(MessageType.CLOSED)
94+
expect(webSocketOnMessageStub.firstCall.args[0][1]).to.equal('q1')
95+
})
96+
97+
it('returns CLOSED when the query ID is too long', async () => {
98+
handler = new CountMessageHandler(webSocket, eventRepository, () => ({
99+
limits: {
100+
client: {
101+
subscription: {
102+
maxFilters: 10,
103+
maxSubscriptionIdLength: 2,
104+
},
105+
},
106+
},
107+
}) as Settings)
70108

71-
const message = [MessageType.COUNT, 'q1', { kinds: [1] }, { kinds: [2] }] as any
109+
const message = [MessageType.COUNT, 'q123', {}] as any
72110

73-
await handler.handleMessage(message)
111+
await handler.handleMessage(message)
74112

75-
expect(eventRepository.countByFilters).to.not.have.been.called
76-
expect(onMessageStub).to.have.been.calledOnce
77-
expect(onMessageStub.firstCall.args[0][0]).to.equal(MessageType.CLOSED)
78-
expect(onMessageStub.firstCall.args[0][1]).to.equal('q1')
79-
})
113+
expect(eventRepository.countByFilters).to.not.have.been.called
114+
expect(webSocketOnMessageStub).to.have.been.calledOnce
115+
expect(webSocketOnMessageStub.firstCall.args[0][0]).to.equal(MessageType.CLOSED)
116+
expect(webSocketOnMessageStub.firstCall.args[0][1]).to.equal('q123')
117+
})
80118

81-
it('emits CLOSED message when repository fails', async () => {
82-
(eventRepository.countByFilters as sinon.SinonStub).rejects(new Error('boom'))
83-
const message = [MessageType.COUNT, 'q1', {}] as any
119+
it('returns CLOSED when counting fails in the repository', async () => {
120+
const countByFiltersStub = eventRepository.countByFilters as Sinon.SinonStub
121+
countByFiltersStub.rejects(new Error('boom'))
122+
const message = [MessageType.COUNT, 'q1', {}] as any
84123

85-
await handler.handleMessage(message)
124+
await handler.handleMessage(message)
86125

87-
expect(onMessageStub).to.have.been.calledOnceWithExactly([
88-
MessageType.CLOSED,
89-
'q1',
90-
'error: unable to count events',
91-
])
126+
expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly([
127+
MessageType.CLOSED,
128+
'q1',
129+
'error: unable to count events',
130+
])
131+
})
92132
})
93133
})

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,18 @@ describe('EventRepository', () => {
439439
expect(result).to.equal(42)
440440
})
441441

442+
it('uses countDistinct on event_id to avoid duplicate counts', async () => {
443+
const countDistinctStub = sandbox.stub().returns({
444+
first: async () => ({ count: '1' }),
445+
})
446+
447+
sandbox.stub(rrDbClient, 'from').returns({ countDistinct: countDistinctStub } as any)
448+
449+
await repository.countByFilters([{ '#e': ['aaaaaa'] } as any])
450+
451+
expect(countDistinctStub).to.have.been.calledOnceWithExactly({ count: 'event_id' })
452+
})
453+
442454
it('builds union query when there are multiple filters', async () => {
443455
const fromStub = sandbox.stub(rrDbClient, 'from').returns({
444456
countDistinct: () => ({
@@ -464,6 +476,20 @@ describe('EventRepository', () => {
464476
const sql = fromStub.firstCall.args[0].toString()
465477
expect(sql).to.include('left join "event_tags"')
466478
expect(sql).to.include('event_tags.tag_name')
479+
expect(sql).to.include('event_tags.tag_value')
480+
})
481+
482+
it('applies limit ordering when a filter includes limit', async () => {
483+
const fromStub = sandbox.stub(rrDbClient, 'from').returns({
484+
countDistinct: () => ({
485+
first: async () => ({ count: '1' }),
486+
}),
487+
} as any)
488+
489+
await repository.countByFilters([{ limit: 3 }])
490+
491+
const sql = fromStub.firstCall.args[0].toString()
492+
expect(sql).to.include('order by "event_created_at" DESC, "event_id" asc limit 3')
467493
})
468494

469495
it('filters out deleted and expired events', async () => {

test/unit/schemas/message-schema.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,13 @@ describe('NIP-01', () => {
152152
expect(result).to.have.property('error').that.is.not.undefined
153153
})
154154

155+
it('returns error if filter is not an object', () => {
156+
message[2] = null
157+
158+
const result = validateSchema(messageSchema)(message)
159+
expect(result).to.have.property('error').that.is.not.undefined
160+
})
161+
155162
it('returns error if there are too many filters', () => {
156163
;(message as any[]).splice(2, 1)
157164
;(message as any[]).push(...range(0, 11).map(() => ({})))

0 commit comments

Comments
 (0)