diff --git a/src/filter.ts b/src/filter.ts index dd968f6..c399fab 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -1,5 +1,7 @@ +import { FilterJSON } from "./filter_json"; import { FilterMatch } from "./filter_match"; export const Filter = { ...FilterMatch, + ...FilterJSON, }; diff --git a/src/filter_json.test.ts b/src/filter_json.test.ts new file mode 100644 index 0000000..227db31 --- /dev/null +++ b/src/filter_json.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, test } from "vitest"; + +import { FilterJSON } from "./filter_json"; +import type { FilterData } from "./types"; + +interface FilterMarshalTestCase { + name: string; + filter: FilterData; + expected: string; +} + +interface FilterUnmarshalTestCase { + name: string; + input: string; + expected: FilterData; +} + +interface FilterRoundTripTestCase { + name: string; + filter: FilterData; +} + +const marshalTestCases: FilterMarshalTestCase[] = [ + { + name: "empty filter", + filter: {}, + expected: "{}", + }, + { + name: "undefined IDs", + filter: { ids: undefined }, + expected: "{}", + }, + { + name: "null IDs", + filter: { ids: null }, + expected: '{"ids": null}', + }, + { + name: "empty IDs", + filter: { ids: [] }, + expected: '{"ids":[]}', + }, + { + name: "populated IDs", + filter: { ids: ["abc", "123"] }, + expected: '{"ids":["abc","123"]}', + }, + { + name: "undefined Authors", + filter: { authors: undefined }, + expected: "{}", + }, + { + name: "null Authors", + filter: { authors: null }, + expected: '{"authors": null}', + }, + { + name: "empty Authors", + filter: { authors: [] }, + expected: '{"authors":[]}', + }, + { + name: "populated Authors", + filter: { authors: ["abc", "123"] }, + expected: '{"authors":["abc","123"]}', + }, + { + name: "undefined Kinds", + filter: { kinds: undefined }, + expected: "{}", + }, + { + name: "null Kinds", + filter: { kinds: null }, + expected: '{"kinds": null}', + }, + { + name: "empty Kinds", + filter: { kinds: [] }, + expected: '{"kinds":[]}', + }, + { + name: "populated Kinds", + filter: { kinds: [1, 20001] }, + expected: '{"kinds":[1,20001]}', + }, + { + name: "undefined Since", + filter: { since: undefined }, + expected: "{}", + }, + { + name: "null Since", + filter: { since: null }, + expected: '{"since": null}', + }, + { + name: "populated Since", + filter: { since: 1000 }, + expected: '{"since":1000}', + }, + { + name: "undefined Until", + filter: { until: undefined }, + expected: "{}", + }, + { + name: "null Until", + filter: { until: null }, + expected: '{"until": null}', + }, + { + name: "populated Until", + filter: { until: 1000 }, + expected: '{"until":1000}', + }, + { + name: "undefined Limit", + filter: { limit: undefined }, + expected: "{}", + }, + { + name: "null Limit", + filter: { limit: null }, + expected: '{"limit": null}', + }, + { + name: "populated Limit", + filter: { limit: 100 }, + expected: '{"limit":100}', + }, + { + name: "all standard fields", + filter: { + ids: ["abc", "123"], + authors: ["def", "456"], + kinds: [1, 200, 3000], + since: 1000, + until: 2000, + limit: 100, + }, + expected: + '{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}', + }, + { + name: "mixed fields", + filter: { ids: undefined, authors: [], kinds: [1] }, + expected: '{"authors":[],"kinds":[1]}', + }, + { + name: "undefined tags map", + filter: { tags: undefined }, + expected: "{}", + }, + { + name: "null tags map", + filter: { tags: null }, + expected: "{}", + }, + { + name: "single-letter tag", + filter: { tags: { e: ["event1"] } }, + expected: '{"#e":["event1"]}', + }, + { + name: "multi-letter tag", + filter: { tags: { emoji: ["🔥", "💧"] } }, + expected: '{"#emoji":["🔥","💧"]}', + }, + { + name: "null tag array", + filter: { tags: { p: null } }, + expected: '{"#p":null}', + }, + { + name: "empty tag array", + filter: { tags: { p: [] } }, + expected: '{"#p":[]}', + }, + { + name: "multiple tags", + filter: { + tags: { + e: ["event1", "event2"], + p: ["pubkey1", "pubkey2"], + }, + }, + expected: '{"#e":["event1","event2"],"#p":["pubkey1","pubkey2"]}', + }, + { + name: "simple extension", + filter: { extensions: { search: "query" } }, + expected: '{"search":"query"}', + }, + { + name: "extension with nested object", + filter: { extensions: { meta: { author: "alice", score: 99 } } }, + expected: '{"meta":{"author":"alice","score":99}}', + }, + { + name: "extension with nested array", + filter: { extensions: { items: [1, 2, 3] } }, + expected: '{"items":[1,2,3]}', + }, + { + name: "extension with complex nested structure", + filter: { extensions: { data: { users: [{ id: 1 }], count: 5 } } }, + expected: '{"data":{"users":[{"id":1}],"count":5}}', + }, + { + name: "multiple extensions", + filter: { extensions: { search: "x", depth: 3 } }, + expected: '{"search":"x","depth":3}', + }, + { + name: "extension collides with standard field - IDs", + filter: { ids: ["real"], extensions: { ids: ["fake"] } }, + expected: '{"ids":["real"]}', + }, + { + name: "extension collides with standard field - Since", + filter: { since: 100, extensions: { since: 999 } }, + expected: '{"since":100}', + }, + { + name: "extension collides with multiple standard fields", + filter: { + authors: ["a"], + kinds: [1], + extensions: { authors: ["b"], kinds: [2] }, + }, + expected: '{"authors":["a"],"kinds":[1]}', + }, + { + name: "extension collides with tag field - #e", + filter: { extensions: { "#e": ["fakeevent"] } }, + expected: "{}", + }, + { + name: "extension collides with standard and tag fields", + filter: { + authors: ["realauthor"], + tags: { e: ["realevent"] }, + extensions: { authors: ["fakeauthor"], "#e": ["fakeevent"] }, + }, + expected: '{"authors":["realauthor"],"#e":["realevent"]}', + }, + { + name: "filter with all field types", + filter: { + ids: ["x"], + since: 100, + tags: { e: ["y"] }, + extensions: { search: "z", ids: ["fakeid"] }, + }, + expected: '{"ids":["x"],"since":100,"#e":["y"],"search":"z"}', + }, +]; + +const unmarshalTestCases: FilterUnmarshalTestCase[] = [ + { + name: "empty object", + input: "{}", + expected: {}, + }, + { + name: "null IDs", + input: '{"ids": null}', + expected: { ids: null }, + }, + { + name: "empty IDs", + input: '{"ids": []}', + expected: { ids: [] }, + }, + { + name: "populated IDs", + input: '{"ids": ["abc","123"]}', + expected: { ids: ["abc", "123"] }, + }, + { + name: "null Authors", + input: '{"authors": null}', + expected: { authors: null }, + }, + { + name: "empty Authors", + input: '{"authors": []}', + expected: { authors: [] }, + }, + { + name: "populated Authors", + input: '{"authors": ["abc","123"]}', + expected: { authors: ["abc", "123"] }, + }, + { + name: "null Kinds", + input: '{"kinds": null}', + expected: { kinds: null }, + }, + { + name: "empty Kinds", + input: '{"kinds": []}', + expected: { kinds: [] }, + }, + { + name: "populated Kinds", + input: '{"kinds": [1,2,3]}', + expected: { kinds: [1, 2, 3] }, + }, + { + name: "null Since", + input: '{"since": null}', + expected: { since: undefined }, + }, + { + name: "populated Since", + input: '{"since": 1000}', + expected: { since: 1000 }, + }, + { + name: "null Until", + input: '{"until": null}', + expected: { until: undefined }, + }, + { + name: "populated Until", + input: '{"until": 1000}', + expected: { until: 1000 }, + }, + { + name: "null Limit", + input: '{"limit": null}', + expected: { limit: undefined }, + }, + { + name: "populated Limit", + input: '{"limit": 1000}', + expected: { limit: 1000 }, + }, + { + name: "all standard fields", + input: + '{"ids":["abc","123"],"authors":["def","456"],"kinds":[1,200,3000],"since":1000,"until":2000,"limit":100}', + expected: { + ids: ["abc", "123"], + authors: ["def", "456"], + kinds: [1, 200, 3000], + since: 1000, + until: 2000, + limit: 100, + }, + }, + { + name: "mixed fields", + input: '{"ids": null, "authors": [], "kinds": [1]}', + expected: { ids: null, authors: [], kinds: [1] }, + }, + { + name: "zero int pointers", + input: '{"since": 0, "until": 0, "limit": 0}', + expected: { since: 0, until: 0, limit: 0 }, + }, + { + name: "single-letter tag", + input: '{"#e":["event1"]}', + expected: { tags: { e: ["event1"] } }, + }, + { + name: "multi-letter tag", + input: '{"#emoji":["🔥","💧"]}', + expected: { tags: { emoji: ["🔥", "💧"] } }, + }, + { + name: "empty tag array", + input: '{"#p":[]}', + expected: { tags: { p: [] } }, + }, + { + name: "multiple tags", + input: '{"#p":["pubkey1","pubkey2"],"#e":["event1","event2"]}', + expected: { + tags: { + p: ["pubkey1", "pubkey2"], + e: ["event1", "event2"], + }, + }, + }, + { + name: "null tag", + input: '{"#p":null}', + expected: { tags: { p: null } }, + }, + { + name: "simple extension", + input: '{"search":"query"}', + expected: { extensions: { search: "query" } }, + }, + { + name: "extension with nested object", + input: '{"meta":{"author":"alice","score":99}}', + expected: { extensions: { meta: { author: "alice", score: 99 } } }, + }, + { + name: "extension with nested array", + input: '{"items":[1,2,3]}', + expected: { extensions: { items: [1, 2, 3] } }, + }, + { + name: "extension with complex nested structure", + input: '{"data":{"level1":{"level2":[{"id":1}]}}}', + expected: { extensions: { data: { level1: { level2: [{ id: 1 }] } } } }, + }, + { + name: "multiple extensions", + input: '{"search":"x","custom":true,"depth":3}', + expected: { extensions: { search: "x", custom: true, depth: 3 } }, + }, + { + name: "extension with null value", + input: '{"optional":null}', + expected: { extensions: { optional: null } }, + }, + { + name: "kitchen sink", + input: '{"ids":["x"],"since":100,"#e":["y"],"search":"z"}', + expected: { + ids: ["x"], + since: 100, + tags: { e: ["y"] }, + extensions: { search: "z" }, + }, + }, +]; + +const roundTripTestCases: FilterRoundTripTestCase[] = [ + { + name: "fully populated filter", + filter: { + ids: ["x"], + since: 100, + tags: { e: ["y"] }, + extensions: { search: "z" }, + }, + }, +]; + +describe("FilterJSON.toJSON", () => { + test.each(marshalTestCases)("$name", ({ filter, expected }) => { + const result = JSON.stringify(FilterJSON.toJSON(filter)); + const expectedObj = JSON.parse(expected); + const actualObj = JSON.parse(result); + expect(actualObj).toEqual(expectedObj); + }); +}); + +describe("FilterJSON.fromJSON", () => { + test.each(unmarshalTestCases)("$name", ({ input, expected }) => { + const result = FilterJSON.fromJSON(JSON.parse(input)); + expectEqualFilters(result, expected); + }); +}); + +describe("FilterJSON round trip", () => { + test.each(roundTripTestCases)("$name", ({ filter }) => { + const jsonBytes = JSON.stringify(FilterJSON.toJSON(filter)); + const result = FilterJSON.fromJSON(JSON.parse(jsonBytes)); + expectEqualFilters(result, filter); + }); +}); + +function expectEqualFilters(got: FilterData, want: FilterData): void { + expect(got.ids).toEqual(want.ids); + expect(got.authors).toEqual(want.authors); + expect(got.kinds).toEqual(want.kinds); + expect(got.since).toEqual(want.since); + expect(got.until).toEqual(want.until); + expect(got.limit).toEqual(want.limit); + expect(got.tags).toEqual(want.tags); + expect(got.extensions).toEqual(want.extensions); +} diff --git a/src/filter_json.ts b/src/filter_json.ts new file mode 100644 index 0000000..ebf0236 --- /dev/null +++ b/src/filter_json.ts @@ -0,0 +1,103 @@ +import type { FilterData, FilterExtensions, TagFilters } from "./types"; + +/** + * Converts a filter to a plain object suitable for JSON.stringify(). + * Merges standard fields, tag filters (prefixed with #), and extensions. + */ +function toJSON(filter: FilterData): object { + const output: Record = {}; + + // Standard fields + if (filter.ids !== undefined) output.ids = filter.ids; + if (filter.authors !== undefined) output.authors = filter.authors; + if (filter.kinds !== undefined) output.kinds = filter.kinds; + if (filter.since !== undefined) output.since = filter.since; + if (filter.until !== undefined) output.until = filter.until; + if (filter.limit !== undefined) output.limit = filter.limit; + + // Tag filters with # prefix + if (filter.tags) { + for (const [tagName, values] of Object.entries(filter.tags)) { + output[`#${tagName}`] = values; + } + } + + // Extensions (block collisions with standard/tag fields) + if (filter.extensions) { + for (const [key, value] of Object.entries(filter.extensions)) { + // Skip if collides with standard field + if ( + ["ids", "authors", "kinds", "since", "until", "limit"].includes(key) + ) { + continue; + } + // Skip if starts with # (tag field collision) + if (key.startsWith("#")) { + continue; + } + output[key] = value; + } + } + + return output; +} + +/** + * Parses a filter from JSON data. + * Separates standard fields, tag filters (keys starting with #), and extensions. + */ +function fromJSON(json: any): FilterData { + const filter: FilterData = {}; + const remaining: Record = { ...json }; + + // Extract standard fields + if ("ids" in remaining) { + filter.ids = remaining.ids; + delete remaining.ids; + } + if ("authors" in remaining) { + filter.authors = remaining.authors; + delete remaining.authors; + } + if ("kinds" in remaining) { + filter.kinds = remaining.kinds; + delete remaining.kinds; + } + if ("since" in remaining) { + filter.since = remaining.since === null ? undefined : remaining.since; + delete remaining.since; + } + if ("until" in remaining) { + filter.until = remaining.until === null ? undefined : remaining.until; + delete remaining.until; + } + if ("limit" in remaining) { + filter.limit = remaining.limit === null ? undefined : remaining.limit; + delete remaining.limit; + } + + // Extract tag filters (keys starting with #) + const tags: TagFilters = {}; + for (const key in remaining) { + if (key.startsWith("#")) { + const tagName = key.slice(1); + tags[tagName] = remaining[key]; + delete remaining[key]; + } + } + if (Object.keys(tags).length > 0) { + filter.tags = tags; + } + + // Remaining fields go to extensions + if (Object.keys(remaining).length > 0) { + filter.extensions = remaining; + } + + return filter; +} + +export const FilterJSON = { + toJSON, + fromJSON, +}; diff --git a/src/filter_match.test.ts b/src/filter_match.test.ts index 3218f0c..e6f0712 100644 --- a/src/filter_match.test.ts +++ b/src/filter_match.test.ts @@ -30,6 +30,21 @@ const filterTestCases: FilterTestCase[] = [ "d39e6f3f", ], }, + { + name: "null id", + filter: { ids: null }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, { name: "empty id", filter: { ids: [] }, @@ -67,6 +82,21 @@ const filterTestCases: FilterTestCase[] = [ filter: { ids: ["ffff"] }, expectedIDs: [], }, + { + name: "null author", + filter: { authors: null }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, { name: "empty author", filter: { authors: [] }, @@ -113,6 +143,21 @@ const filterTestCases: FilterTestCase[] = [ filter: { authors: ["ffff"] }, expectedIDs: [], }, + { + name: "null kind", + filter: { kinds: null }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, { name: "empty kind", filter: { kinds: [] }, @@ -151,9 +196,34 @@ const filterTestCases: FilterTestCase[] = [ expectedIDs: [], }, { - name: "since only", - filter: { since: 5000 }, - expectedIDs: ["7a5d83d4", "3a122100", "4a15d963", "4b03b69a", "d39e6f3f"], + name: "null since", + filter: { since: null }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, + { + name: "null until", + filter: { until: null }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], }, { name: "until only", @@ -170,6 +240,21 @@ const filterTestCases: FilterTestCase[] = [ filter: { since: 10000 }, expectedIDs: [], }, + { + name: "null tag filter", + filter: { tags: { e: null } }, + expectedIDs: [ + "e751d41f", + "562bc378", + "e67fa7b8", + "5e4c64f1", + "7a5d83d4", + "3a122100", + "4a15d963", + "4b03b69a", + "d39e6f3f", + ], + }, { name: "empty tag filter", filter: { tags: { e: [] } }, diff --git a/src/filter_match.ts b/src/filter_match.ts index 6db353b..c65bbad 100644 --- a/src/filter_match.ts +++ b/src/filter_match.ts @@ -24,13 +24,13 @@ function matchesKinds(candidate: number, kinds: number[]): boolean { */ function matchesTimeRange( timestamp: number, - since?: number, - until?: number, + since?: number | null, + until?: number | null, ): boolean { - if (since !== undefined && timestamp < since) { + if (since && timestamp < since) { return false; } - if (until !== undefined && timestamp > until) { + if (until && timestamp > until) { return false; } return true; @@ -56,7 +56,7 @@ function matchesTags(eventTags: Tag[], tagFilters: TagFilters): boolean { // Check each filter requirement for (const [tagName, filterValues] of Object.entries(tagFilters)) { // Empty filter values match all events - if (filterValues.length === 0) continue; + if (!filterValues || filterValues.length === 0) continue; const eventValues = eventIndex.get(tagName); if (!eventValues) return false; diff --git a/src/index.test.js b/src/index.test.js new file mode 100644 index 0000000..15ea7c6 --- /dev/null +++ b/src/index.test.js @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vitest"; + +import * as Roots from "./index"; + +describe("Package Exports", () => { + test("exports type definitions", () => { + // Types aren't runtime values, but we can check the module has them + expect(Roots).toBeDefined(); + }); + + test("exports constants", () => { + expect(Roots.HEX_64_PATTERN).toBeDefined(); + expect(Roots.HEX_128_PATTERN).toBeDefined(); + }); + + test("exports error classes", () => { + expect(Roots.MalformedPubKeyError).toBeDefined(); + expect(Roots.MalformedPrivKeyError).toBeDefined(); + expect(Roots.MalformedIDError).toBeDefined(); + expect(Roots.MalformedSigError).toBeDefined(); + expect(Roots.MalformedTagError).toBeDefined(); + expect(Roots.FailedIDCompError).toBeDefined(); + expect(Roots.NoEventIDError).toBeDefined(); + expect(Roots.InvalidSigError).toBeDefined(); + }); + + test("exports Keys namespace", () => { + expect(Roots.Keys).toBeDefined(); + expect(Roots.Keys.generatePrivateKey).toBeTypeOf("function"); + expect(Roots.Keys.getPublicKey).toBeTypeOf("function"); + }); + + test("exports Event namespace", () => { + expect(Roots.Event).toBeDefined(); + expect(Roots.Event.serialize).toBeTypeOf("function"); + expect(Roots.Event.getID).toBeTypeOf("function"); + expect(Roots.Event.sign).toBeTypeOf("function"); + expect(Roots.Event.validate).toBeTypeOf("function"); + expect(Roots.Event.validateStructure).toBeTypeOf("function"); + expect(Roots.Event.validateID).toBeTypeOf("function"); + expect(Roots.Event.validateSignature).toBeTypeOf("function"); + expect(Roots.Event.toJSON).toBeTypeOf("function"); + expect(Roots.Event.fromJSON).toBeTypeOf("function"); + }); + + test("exports Filter namespace", () => { + expect(Roots.Filter).toBeDefined(); + expect(Roots.Filter.matches).toBeTypeOf("function"); + expect(Roots.Filter.toJSON).toBeTypeOf("function"); + expect(Roots.Filter.fromJSON).toBeTypeOf("function"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 82e8768..93fe81e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export * from "./types"; +export * from "./constants"; export * from "./errors"; export { Event } from "./event"; +export { Filter } from "./filter"; export { Keys } from "./keys"; diff --git a/src/types.ts b/src/types.ts index 72f79c6..17b626e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,7 +24,7 @@ export interface EventData { * Keys correspond to tag names without the "#" prefix. */ export interface TagFilters { - [tagName: string]: string[]; + [tagName: string]: string[] | null; } /** @@ -40,12 +40,12 @@ export interface FilterExtensions { * All conditions within a filter are applied with AND logic. */ export interface FilterData { - ids?: string[]; - authors?: string[]; - kinds?: number[]; - since?: number; - until?: number; - limit?: number; - tags?: TagFilters; - extensions?: FilterExtensions; + ids?: string[] | null; + authors?: string[] | null; + kinds?: number[] | null; + since?: number | null; + until?: number | null; + limit?: number | null; + tags?: TagFilters | null; + extensions?: FilterExtensions | null; }