Skip to content

Commit c263801

Browse files
authored
Merge branch 'main' into fix/husky-not-found
2 parents 66534a0 + 2418209 commit c263801

13 files changed

Lines changed: 234 additions & 9 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
Implement geohash wildcard/prefix behavior for `#g` filters (closes #265): a
6+
criterion ending in `*` matches any event `g` tag whose value starts with the
7+
prefix before `*`; exact matching (no `*`) is unchanged. Only normal geohash
8+
prefixes are intended as input. This is a Nostream extension, not part of
9+
NIP-12.

src/constants/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export enum EventTags {
5656
Invoice = 'bolt11',
5757
// NIP-03: target event kind on an OpenTimestamps attestation
5858
Kind = 'k',
59+
// NIP-12: geohash tag for location-based queries
60+
Geohash = 'g',
5961
}
6062

6163
export const ALL_RELAYS = 'ALL_RELAYS'

src/repositories/event-repository.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { DBEvent, Event } from '../@types/event'
4040
import { EventPurgeCounts, EventRetentionOptions, IEventRepository, IQueryResult } from '../@types/repositories'
4141
import { toBuffer, toJSON } from '../utils/transform'
4242
import { createLogger } from '../factories/logger-factory'
43-
import { isGenericTagQuery } from '../utils/filter'
43+
import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from '../utils/filter'
4444
import { SubscriptionFilter } from '../@types/subscription'
4545

4646
const even = pipe(modulo(__, 2), equals(0))
@@ -193,8 +193,21 @@ export class EventRepository implements IEventRepository {
193193
isEmpty,
194194
() => andWhereRaw('1 = 0', bd),
195195
forEach(
196-
(criterion: string) =>
197-
void orWhereRaw('event_tags.tag_name = ? AND event_tags.tag_value = ?', [filterName[1], criterion], bd),
196+
(criterion: string) => {
197+
if (isGeohashPrefixCriterion(filterName, criterion)) {
198+
return void orWhereRaw(
199+
'event_tags.tag_name = ? AND event_tags.tag_value LIKE ?',
200+
[filterName[1], `${stripGeohashPrefixWildcard(criterion)}%`],
201+
bd,
202+
)
203+
}
204+
205+
return void orWhereRaw(
206+
'event_tags.tag_name = ? AND event_tags.tag_value = ?',
207+
[filterName[1], criterion],
208+
bd,
209+
)
210+
},
198211
),
199212
)(criteria)
200213
})

src/schemas/base-schema.ts

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

3+
import { GEOHASH_FILTER_PATTERN, GEOHASH_PATTERN } from '../utils/geohash'
4+
35
const lowerHexRegex = /^[0-9a-f]+$/
46

7+
// NIP-12 geohash schemas
8+
export const geohashSchema = z.string().regex(GEOHASH_PATTERN, 'Invalid geohash')
9+
export const geohashFilterValueSchema = z.string().regex(GEOHASH_FILTER_PATTERN, 'Invalid geohash filter')
10+
511
export const prefixSchema = z.string().regex(lowerHexRegex).min(4).max(64)
612

713
export const idSchema = z.string().regex(lowerHexRegex).length(64)

src/schemas/event-schema.ts

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

33
import { EventKinds, EventTags } from '../constants/base'
4-
import { createdAtSchema, idSchema, kindSchema, pubkeySchema, signatureSchema, tagSchema } from './base-schema'
4+
import {
5+
createdAtSchema,
6+
geohashSchema,
7+
idSchema,
8+
kindSchema,
9+
pubkeySchema,
10+
signatureSchema,
11+
tagSchema,
12+
} from './base-schema'
513

614
/**
715
* {
@@ -42,4 +50,15 @@ export const eventSchema = z
4250
}
4351
})
4452
}
53+
54+
// Validate geohash tag values (NIP-12 #g)
55+
event.tags.forEach((tag, index) => {
56+
if (tag[0] === EventTags.Geohash && typeof tag[1] === 'string' && !geohashSchema.safeParse(tag[1]).success) {
57+
ctx.addIssue({
58+
code: z.ZodIssueCode.custom,
59+
message: 'Invalid geohash',
60+
path: ['tags', index, 1],
61+
})
62+
}
63+
})
4564
})

src/schemas/filter-schema.ts

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

3-
import { createdAtSchema, kindSchema, prefixSchema } from './base-schema'
4-
import { isGenericTagQuery } from '../utils/filter'
3+
import { createdAtSchema, geohashFilterValueSchema, kindSchema, prefixSchema } from './base-schema'
4+
import { isGenericTagQuery, isGeohashTagQuery } from '../utils/filter'
55

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

@@ -16,13 +16,27 @@ export const filterSchema = z
1616
})
1717
.catchall(z.array(z.string().max(1024)))
1818
.superRefine((data, ctx) => {
19-
for (const key of Object.keys(data)) {
19+
for (const [key, value] of Object.entries(data)) {
2020
if (!knownFilterKeys.has(key) && !isGenericTagQuery(key)) {
2121
ctx.addIssue({
2222
code: z.ZodIssueCode.custom,
2323
message: `Unknown key: ${key}`,
2424
path: [key],
2525
})
26+
continue
27+
}
28+
29+
// Validate #g filter values: NIP-12 geohash with optional single trailing '*'
30+
if (isGeohashTagQuery(key) && Array.isArray(value)) {
31+
value.forEach((criterion, index) => {
32+
if (typeof criterion === 'string' && !geohashFilterValueSchema.safeParse(criterion).success) {
33+
ctx.addIssue({
34+
code: z.ZodIssueCode.custom,
35+
message: 'Invalid geohash filter',
36+
path: [key, index],
37+
})
38+
}
39+
})
2640
}
2741
}
2842
})

src/utils/event.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { deriveFromSecret } from './secret'
88
import { EventKindsRange } from '../@types/settings'
99
import { fromBuffer } from './transform'
1010
import { getLeadingZeroBits } from './proof-of-work'
11-
import { isGenericTagQuery } from './filter'
11+
import { isGenericTagQuery, isGeohashPrefixCriterion, stripGeohashPrefixWildcard } from './filter'
1212
import { SubscriptionFilter } from '../@types/subscription'
1313
import { WebSocketServerAdapterEvent } from '../constants/adapter'
1414

@@ -40,6 +40,18 @@ export const isEventMatchingFilter =
4040
(filter: SubscriptionFilter) =>
4141
(event: Event): boolean => {
4242
const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix)
43+
const isMatchingGenericTagCriterion = (key: string, criterion: string) => (tag: Tag): boolean => {
44+
const [, tagName] = key
45+
if (tag[0] !== tagName) {
46+
return false
47+
}
48+
49+
if (isGeohashPrefixCriterion(key, criterion)) {
50+
return tag[1].startsWith(stripGeohashPrefixWildcard(criterion))
51+
}
52+
53+
return tag[1] === criterion
54+
}
4355

4456
// NIP-01: Basic protocol flow description
4557

@@ -84,7 +96,7 @@ export const isEventMatchingFilter =
8496
Object.entries(filter)
8597
.filter(([key, criteria]) => isGenericTagQuery(key) && Array.isArray(criteria))
8698
.some(([key, criteria]) => {
87-
return !event.tags.some((tag) => tag[0] === key[1] && criteria.includes(tag[1]))
99+
return !event.tags.some((tag) => criteria.some((criterion) => isMatchingGenericTagCriterion(key, criterion)(tag)))
88100
})
89101
) {
90102
return false

src/utils/filter.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,13 @@
1+
import { EventTags } from '../constants/base'
2+
13
export const isGenericTagQuery = (key: string) => /^#[a-zA-Z]$/.test(key)
4+
5+
// NIP-12 geohash filter helpers
6+
export const geohashTagQuery = `#${EventTags.Geohash}`
7+
8+
export const isGeohashTagQuery = (key: string): boolean => key === geohashTagQuery
9+
10+
export const isGeohashPrefixCriterion = (key: string, criterion: string): boolean =>
11+
isGeohashTagQuery(key) && criterion.endsWith('*')
12+
13+
export const stripGeohashPrefixWildcard = (criterion: string): string => criterion.slice(0, -1)

src/utils/geohash.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Geohash base32 alphabet (excludes 'a', 'i', 'l', 'o')
2+
export const GEOHASH_BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'
3+
4+
// Matches a complete geohash (one or more base32 chars)
5+
export const GEOHASH_PATTERN = /^[0123456789bcdefghjkmnpqrstuvwxyz]+$/
6+
7+
// Matches a geohash filter criterion: one or more base32 chars, with an
8+
// optional single trailing '*' wildcard (NIP-12 prefix matching)
9+
export const GEOHASH_FILTER_PATTERN = /^[0123456789bcdefghjkmnpqrstuvwxyz]+\*?$/

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,28 @@ describe('EventRepository', () => {
320320
})
321321
})
322322

323+
describe('#g', () => {
324+
it('selects geohash tags by prefix when criterion ends with wildcard', () => {
325+
const filters = [{ '#g': ['u4pruyd*'] }]
326+
327+
const query = repository.findByFilters(filters).toString()
328+
329+
expect(query).to.equal(
330+
'select "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'g\' AND event_tags.tag_value LIKE \'u4pruyd%\') order by "event_created_at" asc, "event_id" asc limit 500',
331+
)
332+
})
333+
334+
it('keeps geohash tags exact when criterion has no wildcard', () => {
335+
const filters = [{ '#g': ['u4pruyd'] }]
336+
337+
const query = repository.findByFilters(filters).toString()
338+
339+
expect(query).to.equal(
340+
'select "events".* from "events" left join "event_tags" on "events"."event_id" = "event_tags"."event_id" where (event_tags.tag_name = \'g\' AND event_tags.tag_value = \'u4pruyd\') order by "event_created_at" asc, "event_id" asc limit 500',
341+
)
342+
})
343+
})
344+
323345
describe('#p', () => {
324346
it('selects no events given empty list of #p tags', () => {
325347
const filters = [{ '#p': [] }]

0 commit comments

Comments
 (0)