Completed library conversion.
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
|
import { FilterJSON } from "./filter_json";
|
||||||
import { FilterMatch } from "./filter_match";
|
import { FilterMatch } from "./filter_match";
|
||||||
|
|
||||||
export const Filter = {
|
export const Filter = {
|
||||||
...FilterMatch,
|
...FilterMatch,
|
||||||
|
...FilterJSON,
|
||||||
};
|
};
|
||||||
|
|||||||
483
src/filter_json.test.ts
Normal file
483
src/filter_json.test.ts
Normal 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
103
src/filter_json.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -30,6 +30,21 @@ const filterTestCases: FilterTestCase[] = [
|
|||||||
"d39e6f3f",
|
"d39e6f3f",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "null id",
|
||||||
|
filter: { ids: null },
|
||||||
|
expectedIDs: [
|
||||||
|
"e751d41f",
|
||||||
|
"562bc378",
|
||||||
|
"e67fa7b8",
|
||||||
|
"5e4c64f1",
|
||||||
|
"7a5d83d4",
|
||||||
|
"3a122100",
|
||||||
|
"4a15d963",
|
||||||
|
"4b03b69a",
|
||||||
|
"d39e6f3f",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "empty id",
|
name: "empty id",
|
||||||
filter: { ids: [] },
|
filter: { ids: [] },
|
||||||
@@ -67,6 +82,21 @@ const filterTestCases: FilterTestCase[] = [
|
|||||||
filter: { ids: ["ffff"] },
|
filter: { ids: ["ffff"] },
|
||||||
expectedIDs: [],
|
expectedIDs: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "null author",
|
||||||
|
filter: { authors: null },
|
||||||
|
expectedIDs: [
|
||||||
|
"e751d41f",
|
||||||
|
"562bc378",
|
||||||
|
"e67fa7b8",
|
||||||
|
"5e4c64f1",
|
||||||
|
"7a5d83d4",
|
||||||
|
"3a122100",
|
||||||
|
"4a15d963",
|
||||||
|
"4b03b69a",
|
||||||
|
"d39e6f3f",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "empty author",
|
name: "empty author",
|
||||||
filter: { authors: [] },
|
filter: { authors: [] },
|
||||||
@@ -113,6 +143,21 @@ const filterTestCases: FilterTestCase[] = [
|
|||||||
filter: { authors: ["ffff"] },
|
filter: { authors: ["ffff"] },
|
||||||
expectedIDs: [],
|
expectedIDs: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "null kind",
|
||||||
|
filter: { kinds: null },
|
||||||
|
expectedIDs: [
|
||||||
|
"e751d41f",
|
||||||
|
"562bc378",
|
||||||
|
"e67fa7b8",
|
||||||
|
"5e4c64f1",
|
||||||
|
"7a5d83d4",
|
||||||
|
"3a122100",
|
||||||
|
"4a15d963",
|
||||||
|
"4b03b69a",
|
||||||
|
"d39e6f3f",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "empty kind",
|
name: "empty kind",
|
||||||
filter: { kinds: [] },
|
filter: { kinds: [] },
|
||||||
@@ -151,9 +196,34 @@ const filterTestCases: FilterTestCase[] = [
|
|||||||
expectedIDs: [],
|
expectedIDs: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "since only",
|
name: "null since",
|
||||||
filter: { since: 5000 },
|
filter: { since: null },
|
||||||
expectedIDs: ["7a5d83d4", "3a122100", "4a15d963", "4b03b69a", "d39e6f3f"],
|
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",
|
name: "until only",
|
||||||
@@ -170,6 +240,21 @@ const filterTestCases: FilterTestCase[] = [
|
|||||||
filter: { since: 10000 },
|
filter: { since: 10000 },
|
||||||
expectedIDs: [],
|
expectedIDs: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "null tag filter",
|
||||||
|
filter: { tags: { e: null } },
|
||||||
|
expectedIDs: [
|
||||||
|
"e751d41f",
|
||||||
|
"562bc378",
|
||||||
|
"e67fa7b8",
|
||||||
|
"5e4c64f1",
|
||||||
|
"7a5d83d4",
|
||||||
|
"3a122100",
|
||||||
|
"4a15d963",
|
||||||
|
"4b03b69a",
|
||||||
|
"d39e6f3f",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "empty tag filter",
|
name: "empty tag filter",
|
||||||
filter: { tags: { e: [] } },
|
filter: { tags: { e: [] } },
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ function matchesKinds(candidate: number, kinds: number[]): boolean {
|
|||||||
*/
|
*/
|
||||||
function matchesTimeRange(
|
function matchesTimeRange(
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
since?: number,
|
since?: number | null,
|
||||||
until?: number,
|
until?: number | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (since !== undefined && timestamp < since) {
|
if (since && timestamp < since) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (until !== undefined && timestamp > until) {
|
if (until && timestamp > until) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -56,7 +56,7 @@ function matchesTags(eventTags: Tag[], tagFilters: TagFilters): boolean {
|
|||||||
// Check each filter requirement
|
// Check each filter requirement
|
||||||
for (const [tagName, filterValues] of Object.entries(tagFilters)) {
|
for (const [tagName, filterValues] of Object.entries(tagFilters)) {
|
||||||
// Empty filter values match all events
|
// Empty filter values match all events
|
||||||
if (filterValues.length === 0) continue;
|
if (!filterValues || filterValues.length === 0) continue;
|
||||||
|
|
||||||
const eventValues = eventIndex.get(tagName);
|
const eventValues = eventIndex.get(tagName);
|
||||||
if (!eventValues) return false;
|
if (!eventValues) return false;
|
||||||
|
|||||||
52
src/index.test.js
Normal file
52
src/index.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
export * from "./constants";
|
||||||
export * from "./errors";
|
export * from "./errors";
|
||||||
export { Event } from "./event";
|
export { Event } from "./event";
|
||||||
|
export { Filter } from "./filter";
|
||||||
export { Keys } from "./keys";
|
export { Keys } from "./keys";
|
||||||
|
|||||||
18
src/types.ts
18
src/types.ts
@@ -24,7 +24,7 @@ export interface EventData {
|
|||||||
* Keys correspond to tag names without the "#" prefix.
|
* Keys correspond to tag names without the "#" prefix.
|
||||||
*/
|
*/
|
||||||
export interface TagFilters {
|
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.
|
* All conditions within a filter are applied with AND logic.
|
||||||
*/
|
*/
|
||||||
export interface FilterData {
|
export interface FilterData {
|
||||||
ids?: string[];
|
ids?: string[] | null;
|
||||||
authors?: string[];
|
authors?: string[] | null;
|
||||||
kinds?: number[];
|
kinds?: number[] | null;
|
||||||
since?: number;
|
since?: number | null;
|
||||||
until?: number;
|
until?: number | null;
|
||||||
limit?: number;
|
limit?: number | null;
|
||||||
tags?: TagFilters;
|
tags?: TagFilters | null;
|
||||||
extensions?: FilterExtensions;
|
extensions?: FilterExtensions | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user