Skip to content

Commit 00240a9

Browse files
CKodidelacameri
andauthored
feat: support uppercase tag filters (#A-Z) in filter schema (#548)
* feat: support uppercase tag filters (#A-Z) in filter schema Updates filter-schema.ts validation regex from /^#[a-z]$/ to /^#[a-zA-Z]$/ so uppercase tag filters like #I, #K, #E, #A (used by NIP-22 comment threading) pass validation. Adds unit test covering NIP-22 uppercase tag filter acceptance. * refactor: reuse isGenericTagQuery in filter schema validation * chore: add changeset for uppercase tag filter support * docs: add tag filter scope section and NIP-22 comment threading test --------- Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent 7edd6c3 commit 00240a9

4 files changed

Lines changed: 45 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
Support uppercase tag filters (#A-Z) in filter schema validation

CONFIGURATION.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ The schema ships with a small, query-driven set of indexes. The most important o
8787
| `events_active_pubkey_kind_created_at_idx` | `REQ` with `authors`+`kinds` ordered by `created_at DESC, event_id ASC`; `hasActiveRequestToVanish`; by-pubkey deletes. Composite key `(event_pubkey, event_kind, event_created_at DESC, event_id)` so the ORDER BY tie-breaker is satisfied from the index without a sort step. |
8888
| `events_deleted_at_partial_idx` | Retention purge over soft-deleted rows. Partial on `deleted_at IS NOT NULL`. |
8989
| `invoices_pending_created_at_idx` | `findPendingInvoices` poll (`ORDER BY created_at ASC`). Partial on `status = 'pending'`. |
90-
| `event_tags (tag_name, tag_value)` | NIP-01 generic tag filters (`#e`, `#p`, …) via the normalized `event_tags` table. |
90+
| `event_tags (tag_name, tag_value)` | NIP-01 generic tag filters (`#e`, `#p`, `#K`, `#I`, …) via the normalized `event_tags` table. Both lowercase and uppercase single-letter tag filters are supported. |
9191
| `events_event_created_at_index` | Time-range scans (`since` / `until`). |
9292
| `events_event_kind_index` | Kind-only filters and purge kind-whitelist logic. |
9393

@@ -108,6 +108,17 @@ npm run db:verify-index-impact
108108

109109
The hot-path index migration (`20260420_120000_add_hot_path_indexes.js`) uses `CREATE INDEX CONCURRENTLY`, so it can be applied to a running relay without taking `ACCESS EXCLUSIVE` locks on the `events` or `invoices` tables.
110110

111+
## Tag filter scope
112+
113+
Subscription filters support single-letter tag filters using the `#<letter>` key syntax (NIP-01). Both lowercase (`#a``#z`) and uppercase (`#A``#Z`) variants are accepted.
114+
115+
| Scope | Examples | Usage |
116+
|-------|---------|-------|
117+
| Lowercase (`#a``#z`) | `#e`, `#p`, `#a`, `#k` | Standard NIP-01 tag queries; parent-level references in NIP-22 comment threading |
118+
| Uppercase (`#A``#Z`) | `#E`, `#P`, `#A`, `#K`, `#I` | Root-level references in NIP-22 comment threading and other NIPs that use uppercase to distinguish root vs. parent scope |
119+
120+
**NIP-22 comment threading (kind 1111):** NIP-22 comment events use lowercase tags (`#e`, `#a`, `#i`, `#k`) to reference the immediate parent and uppercase tags (`#E`, `#A`, `#I`, `#K`) to reference the root item. Filters must therefore accept both cases to allow clients to query the full comment thread hierarchy. For example, to find all comments on a root event: `{"kinds":[1111],"#E":["<root-event-id>"]}`, or to find comments of a specific root kind: `{"kinds":[1111],"#K":["1"]}`.
121+
111122
# Settings
112123

113124
Running `nostream` for the first time creates the settings file in `<project_root>/.nostr/settings.yaml`. If the file is not created and an error is thrown ensure that the `<project_root>/.nostr` folder exists. The configuration directory can be changed by setting the `NOSTR_CONFIG_DIR` environment variable. `nostream` will pick up any changes to this settings file without needing to restart.

src/schemas/filter-schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod'
22

33
import { createdAtSchema, kindSchema, prefixSchema } from './base-schema'
4+
import { isGenericTagQuery } from '../utils/filter'
45

56
const knownFilterKeys = new Set(['ids', 'authors', 'kinds', 'since', 'until', 'limit'])
67

@@ -16,7 +17,7 @@ export const filterSchema = z
1617
.catchall(z.array(z.string().min(1).max(1024)))
1718
.superRefine((data, ctx) => {
1819
for (const key of Object.keys(data)) {
19-
if (!knownFilterKeys.has(key) && !/^#[a-z]$/.test(key)) {
20+
if (!knownFilterKeys.has(key) && !isGenericTagQuery(key)) {
2021
ctx.addIssue({
2122
code: z.ZodIssueCode.custom,
2223
message: `Unknown key: ${key}`,

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,32 @@ describe('NIP-01', () => {
2222
}
2323
})
2424

25+
it('accepts NIP-22 comment threading filters for kind 1111', () => {
26+
const nip22Filter = {
27+
kinds: [1111],
28+
'#E': ['aaaa'],
29+
'#K': ['1'],
30+
'#I': ['identifier1'],
31+
'#A': ['10000:pubkey:dtag'],
32+
}
33+
const result = validateSchema(filterSchema)(nip22Filter)
34+
expect(result.error).to.be.undefined
35+
expect(result.value).to.deep.equal(nip22Filter)
36+
})
37+
38+
it('accepts uppercase tag filters (#A-Z)', () => {
39+
const filterWithUppercase = {
40+
...filter,
41+
'#I': ['identifier1', 'identifier2'],
42+
'#K': ['1111'],
43+
'#E': ['aa', 'bb'],
44+
'#A': ['10000:pubkey:dtag'],
45+
}
46+
const result = validateSchema(filterSchema)(filterWithUppercase)
47+
expect(result.error).to.be.undefined
48+
expect(result.value).to.deep.equal(filterWithUppercase)
49+
})
50+
2551
it('returns same filter if filter is valid', () => {
2652
const result = validateSchema(filterSchema)(filter)
2753

0 commit comments

Comments
 (0)