Completed library conversion.

This commit is contained in:
Jay
2025-10-24 14:44:25 -04:00
parent eec1b352ed
commit 6726526a48
8 changed files with 744 additions and 17 deletions

View File

@@ -1,5 +1,7 @@
import { FilterJSON } from "./filter_json";
import { FilterMatch } from "./filter_match";
export const Filter = {
...FilterMatch,
...FilterJSON,
};

483
src/filter_json.test.ts Normal file
View File

@@ -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);
}

103
src/filter_json.ts Normal file
View File

@@ -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<string, any> = {};
// 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<string, any> = { ...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,
};

View File

@@ -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: [] } },

View File

@@ -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;

52
src/index.test.js Normal file
View File

@@ -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");
});
});

View File

@@ -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";

View File

@@ -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;
}