Skip to content

Commit ea52a30

Browse files
feat: implement NIP-50 full-text search support
1 parent de14f3c commit ea52a30

20 files changed

Lines changed: 420 additions & 12 deletions

File tree

.changeset/nip-50-search.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"nostream": major
3+
---
4+
5+
Add NIP-50 full-text search support with PostgreSQL `tsvector`/`GIN` indexing.
6+
7+
Clients can now include a `search` field in REQ filter objects to perform full-text
8+
queries against event content. Results are ranked by relevance (`ts_rank`) instead
9+
of the usual `created_at` ordering, per the NIP-50 specification.
10+
11+
Features:
12+
- New `search` filter field accepted in REQ messages
13+
- PostgreSQL GIN index on `to_tsvector('simple', event_content)` for fast full-text lookups
14+
- Configurable text-search language (defaults to `simple`, supports `english`, `spanish`, etc.)
15+
- Configurable max search query length for abuse prevention
16+
- NIP-50 listed in NIP-11 relay information document
17+
- Search can be combined with all existing filter fields (kinds, authors, tags, etc.)

CONFIGURATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta
179179
| nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). |
180180
| nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). |
181181
| nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. |
182+
| nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. |
183+
| nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). |
184+
| nip50.maxQueryLength | Maximum length of the search query string. Queries exceeding this are truncated. Defaults to 256. |
182185
| paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. |
183186
| paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) |
184187
| paymentProcessors.lnurl.invoiceURL | [LUD-06 Pay Request](https://github.com/lnurl/luds/blob/luds/06.md) provider URL. (e.g. https://getalby.com/lnurlp/your-username) |
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
exports.config = { transaction: false }
2+
3+
exports.up = function (knex) {
4+
return knex.raw(
5+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS events_content_fts_idx ON events USING gin (to_tsvector('simple', event_content))",
6+
)
7+
}
8+
9+
exports.down = function (knex) {
10+
return knex.raw('DROP INDEX CONCURRENTLY IF EXISTS events_content_fts_idx')
11+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
33,
2323
40,
2424
44,
25-
45
25+
45,
26+
50
2627
],
2728
"supportedNipExtensions": [],
2829
"main": "src/index.ts",

resources/default-settings.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ nip05:
6262
domainBlacklist: []
6363
nip45:
6464
enabled: true
65+
nip50:
66+
enabled: false
67+
# 'simple' (no stemming) or a language name like 'english', 'spanish'
68+
language: simple
69+
maxQueryLength: 256
6570
network:
6671
maxPayloadSize: 524288
6772
# Uncomment only when using a trusted reverse proxy and configuring trustedProxies.

src/@types/settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ export interface Nip45Settings {
245245
enabled?: boolean
246246
}
247247

248+
export interface Nip50Settings {
249+
enabled?: boolean
250+
language?: string
251+
maxQueryLength?: number
252+
}
253+
248254
export interface Nip05Settings {
249255
mode: Nip05Mode
250256
/**
@@ -276,4 +282,5 @@ export interface Settings {
276282
mirroring?: Mirroring
277283
nip05?: Nip05Settings
278284
nip45?: Nip45Settings
285+
nip50?: Nip50Settings
279286
}

src/@types/subscription.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ export interface SubscriptionFilter {
1010
until?: number
1111
authors?: Pubkey[]
1212
limit?: number
13+
search?: string
1314
[key: `#${string}`]: string[]
1415
}

src/factories/worker-factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const logger = createLogger('worker-factory')
1919
export const workerFactory = (): AppWorker => {
2020
const dbClient = getMasterDbClient()
2121
const readReplicaDbClient = getReadReplicaDbClient()
22-
const eventRepository = new EventRepository(dbClient, readReplicaDbClient)
22+
const eventRepository = new EventRepository(dbClient, readReplicaDbClient, createSettings)
2323
const userRepository = new UserRepository(dbClient, eventRepository)
2424
const nip05VerificationRepository = new Nip05VerificationRepository(dbClient)
2525

src/handlers/request-handlers/root-request-handler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
7070
created_at_upper_limit: createdAtLimits?.maxPositiveDelta,
7171
default_limit: DEFAULT_FILTER_LIMIT,
7272
restricted_writes: hasWriteRestriction,
73+
search_supported: settings.nip50?.enabled ?? false,
7374
},
7475
payments_url: paymentsUrl.toString(),
7576
fees: Object.getOwnPropertyNames(settings.payments.feeSchedules).reduce(

src/handlers/subscribe-message-handler.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { anyPass, equals, isNil, map, propSatisfies, uniqWith } from 'ramda'
1+
import { anyPass, equals, isNil, map, omit, propSatisfies, uniqWith } from 'ramda'
22
// import { addAbortSignal } from 'stream'
33
import { pipeline } from 'stream/promises'
44

@@ -38,7 +38,11 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
3838

3939
public async handleMessage(message: SubscribeMessage): Promise<void> {
4040
const subscriptionId = message[1]
41-
const filters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[]
41+
const rawFilters = uniqWith(equals, message.slice(2)) as SubscriptionFilter[]
42+
43+
// NIP-50: strip search from filters when disabled so isEventMatchingFilter ignores it
44+
const nip50Enabled = this.settings()?.nip50?.enabled ?? false
45+
const filters = nip50Enabled ? rawFilters : rawFilters.map(omit(['search'])) as SubscriptionFilter[]
4246

4347
const reason = this.canSubscribe(subscriptionId, filters)
4448
if (reason) {

0 commit comments

Comments
 (0)