Completed library conversion.
This commit is contained in:
@@ -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
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",
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [] } },
|
||||
|
||||
@@ -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
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 "./constants";
|
||||
export * from "./errors";
|
||||
export { Event } from "./event";
|
||||
export { Filter } from "./filter";
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user