diff --git a/bun.lockb b/bun.lockb index 3134477..9692b5c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/filter.test.ts b/filter.test.ts index eb07079..22ec0b7 100644 --- a/filter.test.ts +++ b/filter.test.ts @@ -237,4 +237,247 @@ describe('Filter', () => { expect(getFilterLimit({ '#p': [] })).toEqual(0) }) }) + + describe('NIP-91 AND filters', () => { + describe('matchFilter', () => { + test('should return true when all AND filter values are present', () => { + const filter = { '&t': ['meme', 'cat'] } + const event = buildEvent({ + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ], + }) + const result = matchFilter(filter, event) + expect(result).toEqual(true) + }) + + test('should return false when one AND filter value is missing', () => { + const filter = { '&t': ['meme', 'cat'] } + const event = buildEvent({ + tags: [['t', 'meme']], + }) + const result = matchFilter(filter, event) + expect(result).toEqual(false) + }) + + test('should return false when all AND filter values are missing', () => { + const filter = { '&t': ['meme', 'cat'] } + const event = buildEvent({ + tags: [['t', 'dog']], + }) + const result = matchFilter(filter, event) + expect(result).toEqual(false) + }) + + test('should return true when AND filter has single value that matches', () => { + const filter = { '&t': ['meme'] } + const event = buildEvent({ + tags: [['t', 'meme']], + }) + const result = matchFilter(filter, event) + expect(result).toEqual(true) + }) + + test('should handle multiple AND filters', () => { + const filter = { + '&t': ['meme', 'cat'], + '&p': ['pubkey1', 'pubkey2'], + } + const event = buildEvent({ + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ['p', 'pubkey1'], + ['p', 'pubkey2'], + ], + }) + const result = matchFilter(filter, event) + expect(result).toEqual(true) + }) + + test('should return false when one of multiple AND filters fails', () => { + const filter = { + '&t': ['meme', 'cat'], + '&p': ['pubkey1', 'pubkey2'], + } + const event = buildEvent({ + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ['p', 'pubkey1'], + ], + }) + const result = matchFilter(filter, event) + expect(result).toEqual(false) + }) + + test('NIP-91 example: AND takes precedence, OR excludes AND values', () => { + const filter = { + kinds: [1], + '&t': ['meme', 'cat'], + '#t': ['black', 'white'], + } + // Event with both AND values and one OR value + const event1 = buildEvent({ + kind: 1, + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ['t', 'black'], + ], + }) + expect(matchFilter(filter, event1)).toEqual(true) + + // Event with both AND values and other OR value + const event2 = buildEvent({ + kind: 1, + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ['t', 'white'], + ], + }) + expect(matchFilter(filter, event2)).toEqual(true) + + // Event with both AND values but no OR values + const event3 = buildEvent({ + kind: 1, + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ], + }) + expect(matchFilter(filter, event3)).toEqual(false) + + // Event missing one AND value + const event4 = buildEvent({ + kind: 1, + tags: [ + ['t', 'meme'], + ['t', 'black'], + ], + }) + expect(matchFilter(filter, event4)).toEqual(false) + + // Event with AND values that are also in OR (should be excluded from OR check) + const event5 = buildEvent({ + kind: 1, + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ['t', 'meme'], // duplicate, but AND values should be excluded from OR + ], + }) + expect(matchFilter(filter, event5)).toEqual(false) // No OR values remain after exclusion + }) + + test('should exclude AND values from OR filter matching', () => { + const filter = { + '&t': ['meme'], + '#t': ['meme', 'cat'], + } + // Event has 'meme' (in AND) and 'cat' (in OR, not in AND) + const event1 = buildEvent({ + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ], + }) + expect(matchFilter(filter, event1)).toEqual(true) + + // Event has only 'meme' (in AND, excluded from OR) + const event2 = buildEvent({ + tags: [['t', 'meme']], + }) + expect(matchFilter(filter, event2)).toEqual(false) // No OR values remain + }) + + test('should handle AND filter with empty array', () => { + const filter = { '&t': [] } + const event = buildEvent({ + tags: [['t', 'meme']], + }) + // Empty AND filter should pass (no requirements) + const result = matchFilter(filter, event) + expect(result).toEqual(true) + }) + + test('should work with other filter conditions', () => { + const filter = { + kinds: [1], + authors: ['abc'], + '&t': ['meme', 'cat'], + } + const event = buildEvent({ + kind: 1, + pubkey: 'abc', + tags: [ + ['t', 'meme'], + ['t', 'cat'], + ], + }) + const result = matchFilter(filter, event) + expect(result).toEqual(true) + }) + + test('should work with OR filter only (backward compatibility)', () => { + const filter = { '#t': ['meme', 'cat'] } + const event = buildEvent({ + tags: [['t', 'meme']], + }) + const result = matchFilter(filter, event) + expect(result).toEqual(true) + }) + }) + + describe('mergeFilters', () => { + test('should merge AND filters', () => { + const result = mergeFilters({ '&t': ['meme'] }, { '&t': ['cat'] }) + expect(result).toEqual({ + '&t': ['meme', 'cat'], + }) + }) + + test('should merge AND and OR filters separately', () => { + const result = mergeFilters({ '&t': ['meme'], '#t': ['black'] }, { '&t': ['cat'], '#t': ['white'] }) + expect(result).toEqual({ + '&t': ['meme', 'cat'], + '#t': ['black', 'white'], + }) + }) + + test('should merge mixed filters with AND filters', () => { + const result = mergeFilters({ kinds: [1], '&t': ['meme'], limit: 3 }, { kinds: [2], '&t': ['cat'], limit: 5 }) + expect(result).toEqual({ + kinds: [1, 2], + '&t': ['meme', 'cat'], + limit: 5, + }) + }) + + test('should deduplicate AND filter values', () => { + const result = mergeFilters({ '&t': ['meme', 'cat'] }, { '&t': ['meme', 'dog'] }) + expect(result).toEqual({ + '&t': ['meme', 'cat', 'dog'], + }) + }) + }) + + describe('getFilterLimit', () => { + test('empty AND filters return 0', () => { + expect(getFilterLimit({ '&t': [] })).toEqual(0) + }) + + test('should handle AND filters with other conditions', () => { + expect(getFilterLimit({ '&t': ['meme', 'cat'] })).toEqual(Infinity) + expect(getFilterLimit({ ids: ['123'], '&t': ['meme'] })).toEqual(1) + }) + + test('should handle both AND and OR filters', () => { + expect(getFilterLimit({ '&t': ['meme'], '#p': ['pubkey1'] })).toEqual(Infinity) + expect(getFilterLimit({ '&t': [], '#p': [] })).toEqual(0) + }) + }) + }) }) diff --git a/filter.ts b/filter.ts index a469b31..0a8fea4 100644 --- a/filter.ts +++ b/filter.ts @@ -10,6 +10,7 @@ export type Filter = { limit?: number search?: string [key: `#${string}`]: string[] | undefined + [key: `&${string}`]: string[] | undefined } export function matchFilter(filter: Filter, event: Event): boolean { @@ -23,11 +24,43 @@ export function matchFilter(filter: Filter, event: Event): boolean { return false } + // Track AND filter values to exclude from OR filters + const andFilterValues: Map> = new Map() + + // Process AND filters first (they take precedence) + for (let f in filter) { + if (f[0] === '&') { + let tagName = f.slice(1) + let values = filter[`&${tagName}` as `&${string}`] + if (values) { + // Check that ALL values in the AND filter are present in the event's tags + const eventTagValues = new Set(event.tags.filter(([t]) => t === tagName).map(([, v]) => v)) + for (let i = 0; i < values.length; i++) { + if (!eventTagValues.has(values[i])) return false + } + + // Store AND filter values to exclude from OR filters + andFilterValues.set(tagName, new Set(values)) + } + } + } + + // Process OR filters (excluding values that appear in AND filters) for (let f in filter) { if (f[0] === '#') { let tagName = f.slice(1) let values = filter[`#${tagName}`] - if (values && !event.tags.find(([t, v]) => t === f.slice(1) && values!.indexOf(v) !== -1)) return false + if (values) { + // Exclude values that are in the corresponding AND filter + const andValues = andFilterValues.get(tagName) + const orValues = andValues ? values.filter(v => !andValues.has(v)) : values + + // If all OR values were excluded, the OR filter cannot be satisfied + if (orValues.length === 0) return false + + // Check if any of the remaining OR values match + if (!event.tags.find(([t, v]) => t === tagName && orValues.indexOf(v) !== -1)) return false + } } } @@ -51,7 +84,13 @@ export function mergeFilters(...filters: Filter[]): Filter { for (let i = 0; i < filters.length; i++) { let filter = filters[i] Object.entries(filter).forEach(([property, values]) => { - if (property === 'kinds' || property === 'ids' || property === 'authors' || property[0] === '#') { + if ( + property === 'kinds' || + property === 'ids' || + property === 'authors' || + property[0] === '#' || + property[0] === '&' + ) { // @ts-ignore result[property] = result[property] || [] // @ts-ignore @@ -82,7 +121,7 @@ export function getFilterLimit(filter: Filter): number { if (filter.authors && !filter.authors.length) return 0 for (const [key, value] of Object.entries(filter)) { - if (key[0] === '#' && Array.isArray(value) && !value.length) return 0 + if ((key[0] === '#' || key[0] === '&') && Array.isArray(value) && !value.length) return 0 } return Math.min(