@hasna/events 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -204,6 +204,52 @@ events webhooks test ops
204
204
  events webhooks remove ops
205
205
  ```
206
206
 
207
+ Field filters can match nested `data` or `metadata` values. Plain
208
+ `--data`/`--metadata` values are strings, which keeps ids and slugs such as
209
+ `001` intact. Use `--data-json` or `--metadata-json` for typed JSON predicates.
210
+ Dot paths access nested object keys; dots inside key names are not escaped yet.
211
+ When the actual event value is an array, string filters match any primitive
212
+ array member, which is useful for tag routing such as `data.tags=auto:route`.
213
+ Use `path!=value` or `path!=json` for negative predicates such as
214
+ `metadata-json 'automation.no_auto!=true'`. Wildcard behavior stays broad for
215
+ legacy source/type/subject filters. For field paths ending in `_path` or `.path`,
216
+ `*` matches one path segment and `**` matches recursively.
217
+
218
+ ```bash
219
+ events webhooks add loops \
220
+ --id open-source-task-route \
221
+ --transport command \
222
+ --source todos \
223
+ --type task.created \
224
+ --timeout-ms 15000 \
225
+ --retry-attempts 3 \
226
+ --retry-backoff-ms 500 \
227
+ --metadata 'project_path=/home/hasna/workspace/hasna/opensource/*' \
228
+ --metadata-json 'route_enabled=true' \
229
+ --metadata-json 'automation.no_auto!=true' \
230
+ --data 'tags=auto:route' \
231
+ --arg events \
232
+ --arg handle \
233
+ --arg todos-task
234
+
235
+ # Command args that begin with dashes can be passed either form:
236
+ events webhooks add events --id json-route --transport command --arg --json
237
+ events webhooks add events --id json-route --transport command --arg=--json
238
+
239
+ # For nested CLIs, put child positional args and flags after an explicit delimiter.
240
+ events webhooks add events --id nested-route --transport command -- handle todos-task --json
241
+
242
+ events webhooks match open-source-task-route \
243
+ --source todos \
244
+ --type task.created \
245
+ --metadata '{"project_path":"/home/hasna/workspace/hasna/opensource/open-events","route_enabled":true}'
246
+
247
+ events webhooks test open-source-task-route --honor-filters \
248
+ --source todos \
249
+ --type task.created \
250
+ --metadata '{"project_path":"/tmp/outside","route_enabled":true}'
251
+ ```
252
+
207
253
  Emit, list, and replay:
208
254
 
209
255
  ```bash
package/dist/cli/index.js CHANGED
@@ -18,29 +18,70 @@ function getPathValue(input, path) {
18
18
  return;
19
19
  }, input);
20
20
  }
21
- function wildcardToRegExp(pattern) {
22
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
23
- return new RegExp(`^${escaped}$`);
21
+ function wildcardToRegExp(pattern, options = {}) {
22
+ let body = "";
23
+ for (let index = 0;index < pattern.length; index += 1) {
24
+ const char = pattern[index];
25
+ if (char === "*") {
26
+ if (pattern[index + 1] === "*") {
27
+ body += ".*";
28
+ index += 1;
29
+ } else {
30
+ body += options.segmentSafe ? "[^/]*" : ".*";
31
+ }
32
+ } else {
33
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
34
+ }
35
+ }
36
+ return new RegExp(`^${body}$`);
24
37
  }
25
- function matchString(value, matcher) {
38
+ function matchString(value, matcher, options = {}) {
26
39
  if (matcher === undefined)
27
40
  return true;
28
41
  if (value === undefined)
29
42
  return false;
30
43
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
31
- return matchers.some((item) => wildcardToRegExp(item).test(value));
44
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
32
45
  }
33
46
  function matchRecord(input, matcher) {
34
47
  if (!matcher)
35
48
  return true;
36
49
  return Object.entries(matcher).every(([path, expected]) => {
37
50
  const actual = getPathValue(input, path);
38
- if (typeof expected === "string" || Array.isArray(expected)) {
39
- return matchString(actual === undefined ? undefined : String(actual), expected);
40
- }
41
- return actual === expected;
51
+ return matchField(actual, expected, path);
42
52
  });
43
53
  }
54
+ function matchField(actual, expected, path) {
55
+ if (isNegativeMatcher(expected)) {
56
+ return !matchPositiveField(actual, expected.not, path);
57
+ }
58
+ return matchPositiveField(actual, expected, path);
59
+ }
60
+ function matchPositiveField(actual, expected, path) {
61
+ if (typeof expected === "string" || Array.isArray(expected)) {
62
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
63
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
64
+ }));
65
+ }
66
+ if (Array.isArray(actual)) {
67
+ return actual.some((item) => item === expected);
68
+ }
69
+ return actual === expected;
70
+ }
71
+ function stringCandidates(actual) {
72
+ if (actual === undefined)
73
+ return [];
74
+ if (Array.isArray(actual)) {
75
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
76
+ }
77
+ return [String(actual)];
78
+ }
79
+ function isPrimitiveFieldValue(value) {
80
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
81
+ }
82
+ function isNegativeMatcher(value) {
83
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
84
+ }
44
85
  function eventMatchesFilter(event, filter) {
45
86
  return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
46
87
  }
@@ -463,7 +504,7 @@ class EventsClient {
463
504
  }
464
505
  return deliveries;
465
506
  }
466
- async testChannel(id, input = {}) {
507
+ async matchChannel(id, input = {}) {
467
508
  const channel = await this.store.getChannel(id);
468
509
  if (!channel)
469
510
  throw new Error(`Channel not found: ${id}`);
@@ -480,6 +521,34 @@ class EventsClient {
480
521
  time: input.time,
481
522
  id: input.id
482
523
  });
524
+ const matched = channelMatchesEvent(channel, event);
525
+ return {
526
+ channelId: channel.id,
527
+ matched,
528
+ event,
529
+ filters: channel.filters,
530
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
531
+ };
532
+ }
533
+ async testChannel(id, input = {}, options = {}) {
534
+ const channel = await this.store.getChannel(id);
535
+ if (!channel)
536
+ throw new Error(`Channel not found: ${id}`);
537
+ const match = await this.matchChannel(id, input);
538
+ const event = match.event;
539
+ if (options.honorFilters && !match.matched) {
540
+ const timestamp = new Date().toISOString();
541
+ const result2 = createDeliveryResult(event, channel, [{
542
+ attempt: 1,
543
+ status: "skipped",
544
+ startedAt: timestamp,
545
+ completedAt: timestamp,
546
+ error: match.reason
547
+ }]);
548
+ result2.metadata = { reason: "filter_mismatch" };
549
+ await this.store.appendDelivery(result2);
550
+ return result2;
551
+ }
483
552
  const eventForChannel = await this.applyRedaction(event, channel);
484
553
  const result = await this.deliverWithRetry(eventForChannel, channel);
485
554
  await this.store.appendDelivery(result);
@@ -591,6 +660,78 @@ function normalizeRetryPolicy(policy) {
591
660
  };
592
661
  }
593
662
 
663
+ // src/filter-options.ts
664
+ function parseFieldMatchers(values, label, typed = false) {
665
+ if (!values?.length)
666
+ return;
667
+ const result = {};
668
+ for (const value of values) {
669
+ const parsed = parseMatcherExpression(value, label);
670
+ const path = parsed.path;
671
+ if (path in result)
672
+ throw new Error(`Duplicate ${label} filter path: ${path}`);
673
+ const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
674
+ result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
675
+ }
676
+ return result;
677
+ }
678
+ function parseFilterOptions(options) {
679
+ const filter2 = {};
680
+ if (options.source)
681
+ filter2.source = options.source;
682
+ if (options.type)
683
+ filter2.type = options.type;
684
+ if (options.subject)
685
+ filter2.subject = options.subject;
686
+ if (options.severity)
687
+ filter2.severity = options.severity;
688
+ const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
689
+ const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
690
+ if (Object.keys(data).length > 0)
691
+ filter2.data = data;
692
+ if (Object.keys(metadata).length > 0)
693
+ filter2.metadata = metadata;
694
+ return Object.keys(filter2).length > 0 ? [filter2] : undefined;
695
+ }
696
+ function mergeMatchers(...records) {
697
+ const result = {};
698
+ for (const record of records) {
699
+ if (!record)
700
+ continue;
701
+ for (const [path, value] of Object.entries(record)) {
702
+ if (path in result)
703
+ throw new Error(`Duplicate filter path: ${path}`);
704
+ result[path] = value;
705
+ }
706
+ }
707
+ return result;
708
+ }
709
+ function parseTypedMatcherValue(value, label) {
710
+ const parsed = JSON.parse(value);
711
+ if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
712
+ return parsed;
713
+ }
714
+ throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
715
+ }
716
+ function parseMatcherExpression(value, label) {
717
+ const negativeSeparator = value.indexOf("!=");
718
+ if (negativeSeparator > 0) {
719
+ return {
720
+ path: value.slice(0, negativeSeparator),
721
+ rawValue: value.slice(negativeSeparator + 2),
722
+ negated: true
723
+ };
724
+ }
725
+ const separator = value.indexOf("=");
726
+ if (separator <= 0)
727
+ throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
728
+ return {
729
+ path: value.slice(0, separator),
730
+ rawValue: value.slice(separator + 1),
731
+ negated: false
732
+ };
733
+ }
734
+
594
735
  // src/cli/index.ts
595
736
  function version() {
596
737
  try {
@@ -606,12 +747,23 @@ function parseGlobalArgs(argv) {
606
747
  let dir;
607
748
  for (let index = 0;index < argv.length; index += 1) {
608
749
  const arg = argv[index];
750
+ if (arg === "--") {
751
+ rest.push(...argv.slice(index + 1));
752
+ break;
753
+ }
754
+ if (!arg.startsWith("-")) {
755
+ rest.push(...argv.slice(index));
756
+ break;
757
+ }
609
758
  if (arg === "--json" || arg === "-j") {
610
759
  json = true;
760
+ } else if (arg.startsWith("--dir=")) {
761
+ dir = arg.slice("--dir=".length);
611
762
  } else if (arg === "--dir") {
612
763
  dir = argv[++index];
613
764
  } else {
614
- rest.push(arg);
765
+ rest.push(...argv.slice(index));
766
+ break;
615
767
  }
616
768
  }
617
769
  return { json, dir, rest };
@@ -659,20 +811,16 @@ function parseJsonOption(value, fallback) {
659
811
  return parsed;
660
812
  }
661
813
  function parseFilter(args) {
662
- const filter2 = {};
663
- const type = takeOption(args, "--type") ?? takeOption(args, "--event-type");
664
- const source = takeOption(args, "--source");
665
- const subject = takeOption(args, "--subject");
666
- const severity = takeOption(args, "--severity");
667
- if (type)
668
- filter2.type = type;
669
- if (source)
670
- filter2.source = source;
671
- if (subject)
672
- filter2.subject = subject;
673
- if (severity)
674
- filter2.severity = severity;
675
- return Object.keys(filter2).length > 0 ? [filter2] : undefined;
814
+ return parseFilterOptions({
815
+ type: takeOption(args, "--type") ?? takeOption(args, "--event-type"),
816
+ source: takeOption(args, "--source"),
817
+ subject: takeOption(args, "--subject"),
818
+ severity: takeOption(args, "--severity"),
819
+ data: takeMany(args, "--data"),
820
+ metadata: takeMany(args, "--metadata"),
821
+ dataJson: takeMany(args, "--data-json"),
822
+ metadataJson: takeMany(args, "--metadata-json")
823
+ });
676
824
  }
677
825
  function parseHeaders(values) {
678
826
  if (values.length === 0)
@@ -705,6 +853,8 @@ Usage:
705
853
  ${name} [--dir <path>] [--json] webhooks list
706
854
  ${name} [--dir <path>] [--json] webhooks remove <id>
707
855
  ${name} [--dir <path>] [--json] webhooks test <id>
856
+ ${name} [--dir <path>] [--json] webhooks match <id>
857
+ ${name} [--dir <path>] [--json] webhooks status
708
858
  ${name} [--dir <path>] [--json] status
709
859
  ${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
710
860
  ${name} [--dir <path>] [--json] events list [--limit <n>]
@@ -722,6 +872,8 @@ Usage:
722
872
  ${name} [--dir <path>] [--json] webhooks list
723
873
  ${name} [--dir <path>] [--json] webhooks remove <id>
724
874
  ${name} [--dir <path>] [--json] webhooks test <id>
875
+ ${name} [--dir <path>] [--json] webhooks match <id>
876
+ ${name} [--dir <path>] [--json] webhooks status
725
877
 
726
878
  Options:
727
879
  --id <id> Channel id for add
@@ -729,12 +881,52 @@ Options:
729
881
  --source <source> Event source filter
730
882
  --subject <subject> Event subject filter
731
883
  --severity <severity> Event severity filter
884
+ --data <path=value|path!=value> Event data field filter, repeatable; strings, dot paths, array-member matching, * segment wildcard, ** recursive wildcard
885
+ --metadata <path=value|path!=value> Event metadata field filter, repeatable; strings, dot paths, array-member matching, * segment wildcard, ** recursive wildcard
886
+ --data-json <path=json|path!=json> Event data field filter with typed JSON value
887
+ --metadata-json <path=json|path!=json> Event metadata field filter with typed JSON value
888
+ --honor-filters On webhooks test, skip delivery when the sample event does not match filters
732
889
  --transport <kind> webhook or command
733
890
  --secret <secret> Webhook signing secret
734
891
  --header <name=value> Webhook header, repeatable
735
892
  --redact <path> Redaction path, repeatable
736
893
  --no-deliver Available on events emit`);
737
894
  }
895
+ function printWebhookAddHelp(options = {}) {
896
+ const name = commandName(options);
897
+ console.log(`${name} webhooks add
898
+
899
+ Usage:
900
+ ${name} [--dir <path>] [--json] webhooks add <url|command> [options]
901
+ ${name} [--dir <path>] [--json] webhooks add <command> --transport command [options] -- [command-args...]
902
+
903
+ Options:
904
+ --id <id> Channel id
905
+ --name <name> Display name
906
+ --transport <kind> webhook or command
907
+ --type <pattern> Event type filter, supports wildcards
908
+ --source <source> Event source filter
909
+ --subject <subject> Event subject filter
910
+ --severity <severity> Event severity filter
911
+ --data <path=value|path!=value> Event data field filter, repeatable
912
+ --metadata <path=value|path!=value> Event metadata field filter, repeatable
913
+ --data-json <path=json|path!=json> Event data field filter with typed JSON value
914
+ --metadata-json <path=json|path!=json> Event metadata field filter with typed JSON value
915
+ --secret <secret> Webhook signing secret
916
+ --header <name=value> Webhook header, repeatable
917
+ --arg <arg> Command argument, repeatable; values may begin with dashes
918
+ --timeout-ms <ms> Transport timeout in milliseconds
919
+ --retry-attempts <n> Maximum delivery attempts
920
+ --retry-backoff-ms <ms> Initial retry backoff in milliseconds
921
+ --redact <path> Redaction path, repeatable
922
+ --disabled Create channel disabled
923
+
924
+ Examples:
925
+ ${name} webhooks add https://example.com/webhooks/hasna --id ops --retry-attempts 3 --retry-backoff-ms 500
926
+ ${name} webhooks add bun --id command-hook --transport command --arg run --arg ./handler.ts --arg --json
927
+ ${name} webhooks add bun --id command-hook --transport command --arg=--json
928
+ ${name} webhooks add bun --id command-hook --transport command -- run ./handler.ts --json`);
929
+ }
738
930
  function printEventsHelp(options = {}) {
739
931
  const name = commandName(options);
740
932
  console.log(`${name} events
@@ -781,6 +973,10 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
781
973
  printWebhooksHelp(options);
782
974
  return;
783
975
  }
976
+ if (command === "add" && (tail[0] === "--help" || tail[0] === "-h")) {
977
+ printWebhookAddHelp(options);
978
+ return;
979
+ }
784
980
  if (tail.includes("--help") || tail.includes("-h")) {
785
981
  printWebhooksHelp(options);
786
982
  return;
@@ -804,7 +1000,7 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
804
1000
  }
805
1001
  async function handleWebhooks(client, command, tail, parsed, options) {
806
1002
  if (command === "add") {
807
- const args = [...tail];
1003
+ const { args, delimiterArgs } = splitDelimiter(tail);
808
1004
  const transport = takeOption(args, "--transport") ?? "webhook";
809
1005
  const id = takeOption(args, "--id") ?? crypto.randomUUID();
810
1006
  const name = takeOption(args, "--name");
@@ -835,7 +1031,7 @@ async function handleWebhooks(client, command, tail, parsed, options) {
835
1031
  if (transport === "webhook") {
836
1032
  channel.webhook = { url: target, secret, headers: parseHeaders(headerValues), timeoutMs };
837
1033
  } else if (transport === "command") {
838
- channel.command = { command: target, args: [...args.slice(1), ...commandArgs], timeoutMs };
1034
+ channel.command = { command: target, args: [...args.slice(1), ...commandArgs, ...delimiterArgs], timeoutMs };
839
1035
  } else {
840
1036
  throw new Error(`Transport ${transport} is reserved for future use and cannot be added yet`);
841
1037
  }
@@ -857,6 +1053,14 @@ async function handleWebhooks(client, command, tail, parsed, options) {
857
1053
  });
858
1054
  return;
859
1055
  }
1056
+ if (command === "status") {
1057
+ const status = await getEventsStatus(parsed.dir);
1058
+ output(parsed, status, () => {
1059
+ console.log(`events dataDir: ${status.dataDir}`);
1060
+ console.log(`${status.counts.enabledChannels}/${status.counts.channels} channel(s) enabled`);
1061
+ });
1062
+ return;
1063
+ }
860
1064
  if (command === "remove") {
861
1065
  const id = tail[0];
862
1066
  if (!id)
@@ -870,17 +1074,45 @@ async function handleWebhooks(client, command, tail, parsed, options) {
870
1074
  const id = args.shift();
871
1075
  if (!id)
872
1076
  throw new Error("webhooks test requires a channel id");
1077
+ const honorFilters = takeFlag(args, "--honor-filters");
873
1078
  const result = await client.testChannel(id, {
874
1079
  source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
875
1080
  type: takeOption(args, "--type") ?? "events.test",
876
1081
  subject: takeOption(args, "--subject") ?? id,
877
- data: parseJsonOption(takeOption(args, "--data"), { test: true })
878
- });
1082
+ message: takeOption(args, "--message") ?? "Hasna events test delivery",
1083
+ data: parseJsonOption(takeOption(args, "--data"), { test: true }),
1084
+ metadata: parseJsonOption(takeOption(args, "--metadata"), {})
1085
+ }, { honorFilters });
879
1086
  output(parsed, result, () => console.log(`${result.status}: ${result.channelId}`));
880
1087
  return;
881
1088
  }
1089
+ if (command === "match") {
1090
+ const args = [...tail];
1091
+ const id = args.shift();
1092
+ if (!id)
1093
+ throw new Error("webhooks match requires a channel id");
1094
+ const result = await client.matchChannel(id, {
1095
+ source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
1096
+ type: takeOption(args, "--type") ?? "events.test",
1097
+ subject: takeOption(args, "--subject") ?? id,
1098
+ message: takeOption(args, "--message") ?? "Hasna events match preview",
1099
+ data: parseJsonOption(takeOption(args, "--data"), { test: true }),
1100
+ metadata: parseJsonOption(takeOption(args, "--metadata"), {})
1101
+ });
1102
+ output(parsed, result, () => console.log(`${result.matched ? "matched" : "skipped"}: ${result.channelId}`));
1103
+ return;
1104
+ }
882
1105
  throw new Error(`Unknown webhooks command: ${command ?? ""}`);
883
1106
  }
1107
+ function splitDelimiter(values) {
1108
+ const delimiterIndex = values.indexOf("--");
1109
+ if (delimiterIndex === -1)
1110
+ return { args: [...values], delimiterArgs: [] };
1111
+ return {
1112
+ args: values.slice(0, delimiterIndex),
1113
+ delimiterArgs: values.slice(delimiterIndex + 1)
1114
+ };
1115
+ }
884
1116
  async function handleEvents(client, command, tail, parsed, options) {
885
1117
  if (command === "emit") {
886
1118
  const args = [...tail];
package/dist/commander.js CHANGED
@@ -8,29 +8,70 @@ function getPathValue(input, path) {
8
8
  return;
9
9
  }, input);
10
10
  }
11
- function wildcardToRegExp(pattern) {
12
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
13
- return new RegExp(`^${escaped}$`);
11
+ function wildcardToRegExp(pattern, options = {}) {
12
+ let body = "";
13
+ for (let index = 0;index < pattern.length; index += 1) {
14
+ const char = pattern[index];
15
+ if (char === "*") {
16
+ if (pattern[index + 1] === "*") {
17
+ body += ".*";
18
+ index += 1;
19
+ } else {
20
+ body += options.segmentSafe ? "[^/]*" : ".*";
21
+ }
22
+ } else {
23
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
24
+ }
25
+ }
26
+ return new RegExp(`^${body}$`);
14
27
  }
15
- function matchString(value, matcher) {
28
+ function matchString(value, matcher, options = {}) {
16
29
  if (matcher === undefined)
17
30
  return true;
18
31
  if (value === undefined)
19
32
  return false;
20
33
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
21
- return matchers.some((item) => wildcardToRegExp(item).test(value));
34
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
22
35
  }
23
36
  function matchRecord(input, matcher) {
24
37
  if (!matcher)
25
38
  return true;
26
39
  return Object.entries(matcher).every(([path, expected]) => {
27
40
  const actual = getPathValue(input, path);
28
- if (typeof expected === "string" || Array.isArray(expected)) {
29
- return matchString(actual === undefined ? undefined : String(actual), expected);
30
- }
31
- return actual === expected;
41
+ return matchField(actual, expected, path);
32
42
  });
33
43
  }
44
+ function matchField(actual, expected, path) {
45
+ if (isNegativeMatcher(expected)) {
46
+ return !matchPositiveField(actual, expected.not, path);
47
+ }
48
+ return matchPositiveField(actual, expected, path);
49
+ }
50
+ function matchPositiveField(actual, expected, path) {
51
+ if (typeof expected === "string" || Array.isArray(expected)) {
52
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
53
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
54
+ }));
55
+ }
56
+ if (Array.isArray(actual)) {
57
+ return actual.some((item) => item === expected);
58
+ }
59
+ return actual === expected;
60
+ }
61
+ function stringCandidates(actual) {
62
+ if (actual === undefined)
63
+ return [];
64
+ if (Array.isArray(actual)) {
65
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
66
+ }
67
+ return [String(actual)];
68
+ }
69
+ function isPrimitiveFieldValue(value) {
70
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
71
+ }
72
+ function isNegativeMatcher(value) {
73
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
74
+ }
34
75
  function eventMatchesFilter(event, filter) {
35
76
  return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
36
77
  }
@@ -474,7 +515,7 @@ class EventsClient {
474
515
  }
475
516
  return deliveries;
476
517
  }
477
- async testChannel(id, input = {}) {
518
+ async matchChannel(id, input = {}) {
478
519
  const channel = await this.store.getChannel(id);
479
520
  if (!channel)
480
521
  throw new Error(`Channel not found: ${id}`);
@@ -491,6 +532,34 @@ class EventsClient {
491
532
  time: input.time,
492
533
  id: input.id
493
534
  });
535
+ const matched = channelMatchesEvent(channel, event);
536
+ return {
537
+ channelId: channel.id,
538
+ matched,
539
+ event,
540
+ filters: channel.filters,
541
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
542
+ };
543
+ }
544
+ async testChannel(id, input = {}, options = {}) {
545
+ const channel = await this.store.getChannel(id);
546
+ if (!channel)
547
+ throw new Error(`Channel not found: ${id}`);
548
+ const match = await this.matchChannel(id, input);
549
+ const event = match.event;
550
+ if (options.honorFilters && !match.matched) {
551
+ const timestamp = new Date().toISOString();
552
+ const result2 = createDeliveryResult(event, channel, [{
553
+ attempt: 1,
554
+ status: "skipped",
555
+ startedAt: timestamp,
556
+ completedAt: timestamp,
557
+ error: match.reason
558
+ }]);
559
+ result2.metadata = { reason: "filter_mismatch" };
560
+ await this.store.appendDelivery(result2);
561
+ return result2;
562
+ }
494
563
  const eventForChannel = await this.applyRedaction(event, channel);
495
564
  const result = await this.deliverWithRetry(eventForChannel, channel);
496
565
  await this.store.appendDelivery(result);
@@ -602,6 +671,78 @@ function normalizeRetryPolicy(policy) {
602
671
  };
603
672
  }
604
673
 
674
+ // src/filter-options.ts
675
+ function parseFieldMatchers(values, label, typed = false) {
676
+ if (!values?.length)
677
+ return;
678
+ const result = {};
679
+ for (const value of values) {
680
+ const parsed = parseMatcherExpression(value, label);
681
+ const path = parsed.path;
682
+ if (path in result)
683
+ throw new Error(`Duplicate ${label} filter path: ${path}`);
684
+ const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
685
+ result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
686
+ }
687
+ return result;
688
+ }
689
+ function parseFilterOptions(options) {
690
+ const filter2 = {};
691
+ if (options.source)
692
+ filter2.source = options.source;
693
+ if (options.type)
694
+ filter2.type = options.type;
695
+ if (options.subject)
696
+ filter2.subject = options.subject;
697
+ if (options.severity)
698
+ filter2.severity = options.severity;
699
+ const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
700
+ const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
701
+ if (Object.keys(data).length > 0)
702
+ filter2.data = data;
703
+ if (Object.keys(metadata).length > 0)
704
+ filter2.metadata = metadata;
705
+ return Object.keys(filter2).length > 0 ? [filter2] : undefined;
706
+ }
707
+ function mergeMatchers(...records) {
708
+ const result = {};
709
+ for (const record of records) {
710
+ if (!record)
711
+ continue;
712
+ for (const [path, value] of Object.entries(record)) {
713
+ if (path in result)
714
+ throw new Error(`Duplicate filter path: ${path}`);
715
+ result[path] = value;
716
+ }
717
+ }
718
+ return result;
719
+ }
720
+ function parseTypedMatcherValue(value, label) {
721
+ const parsed = JSON.parse(value);
722
+ if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
723
+ return parsed;
724
+ }
725
+ throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
726
+ }
727
+ function parseMatcherExpression(value, label) {
728
+ const negativeSeparator = value.indexOf("!=");
729
+ if (negativeSeparator > 0) {
730
+ return {
731
+ path: value.slice(0, negativeSeparator),
732
+ rawValue: value.slice(negativeSeparator + 2),
733
+ negated: true
734
+ };
735
+ }
736
+ const separator = value.indexOf("=");
737
+ if (separator <= 0)
738
+ throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
739
+ return {
740
+ path: value.slice(0, separator),
741
+ rawValue: value.slice(separator + 1),
742
+ negated: false
743
+ };
744
+ }
745
+
605
746
  // src/commander.ts
606
747
  function parseJsonObject(value, fallback) {
607
748
  if (!value)
@@ -624,18 +765,6 @@ function parseHeaders(values) {
624
765
  }
625
766
  return headers;
626
767
  }
627
- function parseFilter(options) {
628
- const filter2 = {};
629
- if (options.source)
630
- filter2.source = options.source;
631
- if (options.type)
632
- filter2.type = options.type;
633
- if (options.subject)
634
- filter2.subject = options.subject;
635
- if (options.severity)
636
- filter2.severity = options.severity;
637
- return Object.keys(filter2).length > 0 ? [filter2] : undefined;
638
- }
639
768
  function createClient(options) {
640
769
  if (options.createClient)
641
770
  return options.createClient();
@@ -655,14 +784,14 @@ function wantsJson(actionOptions, command) {
655
784
  }
656
785
  function registerWebhookCommands(program, options) {
657
786
  const webhooks = program.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
658
- webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
787
+ webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--data <path=value...>", "Event data field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--metadata <path=value...>", "Event metadata field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--data-json <path=json...>", "Event data field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--metadata-json <path=json...>", "Event metadata field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
659
788
  const timestamp = new Date().toISOString();
660
789
  const channel = {
661
790
  id: actionOptions.id,
662
791
  name: actionOptions.name,
663
792
  enabled: !actionOptions.disabled,
664
793
  transport: actionOptions.transport,
665
- filters: parseFilter(actionOptions),
794
+ filters: parseFilterOptions(actionOptions),
666
795
  retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
667
796
  redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
668
797
  createdAt: timestamp,
@@ -692,20 +821,36 @@ function registerWebhookCommands(program, options) {
692
821
  console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
693
822
  }
694
823
  });
824
+ webhooks.command("status").description("Show events webhook storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
825
+ const status = await getEventsStatus(options.dataDir);
826
+ print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
827
+ });
695
828
  webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
696
829
  const removed = await createClient(options).removeChannel(id);
697
830
  print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
698
831
  });
699
- webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
832
+ webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--honor-filters", "Skip delivery when the sample event does not match channel filters", false).option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
700
833
  const result = await createClient(options).testChannel(id, {
701
- source: options.source,
834
+ source: actionOptions.source ?? options.source,
702
835
  type: actionOptions.type,
703
836
  subject: actionOptions.subject ?? id,
704
837
  message: actionOptions.message,
705
- data: parseJsonObject(actionOptions.data, { test: true })
706
- });
838
+ data: parseJsonObject(actionOptions.data, { test: true }),
839
+ metadata: parseJsonObject(actionOptions.metadata, {})
840
+ }, { honorFilters: actionOptions.honorFilters });
707
841
  print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
708
842
  });
843
+ webhooks.command("match").description("Check whether a sample event matches one subscription without delivering").argument("<id>", "Subscription/channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events match preview").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
844
+ const result = await createClient(options).matchChannel(id, {
845
+ source: actionOptions.source ?? options.source,
846
+ type: actionOptions.type,
847
+ subject: actionOptions.subject ?? id,
848
+ message: actionOptions.message,
849
+ data: parseJsonObject(actionOptions.data, { test: true }),
850
+ metadata: parseJsonObject(actionOptions.metadata, {})
851
+ });
852
+ print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
853
+ });
709
854
  return webhooks;
710
855
  }
711
856
  function registerEventCommands(program, options) {
@@ -0,0 +1,17 @@
1
+ import type { EventFilter, FieldMatcherValue } from "./types.js";
2
+ type MatcherValue = FieldMatcherValue | {
3
+ not: FieldMatcherValue;
4
+ };
5
+ export interface FilterOptionInput {
6
+ source?: string;
7
+ type?: string;
8
+ subject?: string;
9
+ severity?: string;
10
+ data?: string[];
11
+ metadata?: string[];
12
+ dataJson?: string[];
13
+ metadataJson?: string[];
14
+ }
15
+ export declare function parseFieldMatchers(values: string[] | undefined, label: string, typed?: boolean): Record<string, MatcherValue> | undefined;
16
+ export declare function parseFilterOptions(options: FilterOptionInput): EventFilter[] | undefined;
17
+ export {};
package/dist/filter.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { ChannelConfig, EventEnvelope, EventFilter, StringMatcher } from "./types.js";
2
- export declare function matchString(value: string | undefined, matcher: StringMatcher | undefined): boolean;
2
+ export declare function matchString(value: string | undefined, matcher: StringMatcher | undefined, options?: {
3
+ segmentSafe?: boolean;
4
+ }): boolean;
3
5
  export declare function eventMatchesFilter(event: EventEnvelope, filter: EventFilter): boolean;
4
6
  export declare function channelMatchesEvent(channel: ChannelConfig, event: EventEnvelope): boolean;
package/dist/filter.js CHANGED
@@ -8,29 +8,70 @@ function getPathValue(input, path) {
8
8
  return;
9
9
  }, input);
10
10
  }
11
- function wildcardToRegExp(pattern) {
12
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
13
- return new RegExp(`^${escaped}$`);
11
+ function wildcardToRegExp(pattern, options = {}) {
12
+ let body = "";
13
+ for (let index = 0;index < pattern.length; index += 1) {
14
+ const char = pattern[index];
15
+ if (char === "*") {
16
+ if (pattern[index + 1] === "*") {
17
+ body += ".*";
18
+ index += 1;
19
+ } else {
20
+ body += options.segmentSafe ? "[^/]*" : ".*";
21
+ }
22
+ } else {
23
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
24
+ }
25
+ }
26
+ return new RegExp(`^${body}$`);
14
27
  }
15
- function matchString(value, matcher) {
28
+ function matchString(value, matcher, options = {}) {
16
29
  if (matcher === undefined)
17
30
  return true;
18
31
  if (value === undefined)
19
32
  return false;
20
33
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
21
- return matchers.some((item) => wildcardToRegExp(item).test(value));
34
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
22
35
  }
23
36
  function matchRecord(input, matcher) {
24
37
  if (!matcher)
25
38
  return true;
26
39
  return Object.entries(matcher).every(([path, expected]) => {
27
40
  const actual = getPathValue(input, path);
28
- if (typeof expected === "string" || Array.isArray(expected)) {
29
- return matchString(actual === undefined ? undefined : String(actual), expected);
30
- }
31
- return actual === expected;
41
+ return matchField(actual, expected, path);
32
42
  });
33
43
  }
44
+ function matchField(actual, expected, path) {
45
+ if (isNegativeMatcher(expected)) {
46
+ return !matchPositiveField(actual, expected.not, path);
47
+ }
48
+ return matchPositiveField(actual, expected, path);
49
+ }
50
+ function matchPositiveField(actual, expected, path) {
51
+ if (typeof expected === "string" || Array.isArray(expected)) {
52
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
53
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
54
+ }));
55
+ }
56
+ if (Array.isArray(actual)) {
57
+ return actual.some((item) => item === expected);
58
+ }
59
+ return actual === expected;
60
+ }
61
+ function stringCandidates(actual) {
62
+ if (actual === undefined)
63
+ return [];
64
+ if (Array.isArray(actual)) {
65
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
66
+ }
67
+ return [String(actual)];
68
+ }
69
+ function isPrimitiveFieldValue(value) {
70
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
71
+ }
72
+ function isNegativeMatcher(value) {
73
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
74
+ }
34
75
  function eventMatchesFilter(event, filter) {
35
76
  return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
36
77
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChannelConfig, DeliveryResult, EmitOptions, EmitResult, EventEnvelope, EventInput, EventRedactor, ReplayOptions } from "./types.js";
1
+ import type { ChannelConfig, DeliveryResult, EmitOptions, EmitResult, EventEnvelope, EventFilter, EventInput, EventRedactor, ReplayOptions } from "./types.js";
2
2
  import { type EventsStore } from "./storage.js";
3
3
  import { type TransportDispatchOptions } from "./transports.js";
4
4
  export * from "./types.js";
@@ -11,6 +11,16 @@ export interface EventsClientOptions extends TransportDispatchOptions {
11
11
  dataDir?: string;
12
12
  redactors?: EventRedactor[];
13
13
  }
14
+ export interface ChannelMatchResult {
15
+ channelId: string;
16
+ matched: boolean;
17
+ event: EventEnvelope;
18
+ filters?: EventFilter[];
19
+ reason?: string;
20
+ }
21
+ export interface TestChannelOptions {
22
+ honorFilters?: boolean;
23
+ }
14
24
  export declare function createEvent<TData extends Record<string, unknown>>(input: EventInput<TData>): EventEnvelope<TData>;
15
25
  export declare class EventsClient {
16
26
  private store;
@@ -24,7 +34,8 @@ export declare class EventsClient {
24
34
  listEvents(): Promise<EventEnvelope[]>;
25
35
  listDeliveries(): Promise<DeliveryResult[]>;
26
36
  deliver(event: EventEnvelope): Promise<DeliveryResult[]>;
27
- testChannel(id: string, input?: Partial<EventInput>): Promise<DeliveryResult>;
37
+ matchChannel(id: string, input?: Partial<EventInput>): Promise<ChannelMatchResult>;
38
+ testChannel(id: string, input?: Partial<EventInput>, options?: TestChannelOptions): Promise<DeliveryResult>;
28
39
  replay(options?: ReplayOptions): Promise<{
29
40
  events: EventEnvelope[];
30
41
  deliveries: DeliveryResult[];
package/dist/index.js CHANGED
@@ -8,29 +8,70 @@ function getPathValue(input, path) {
8
8
  return;
9
9
  }, input);
10
10
  }
11
- function wildcardToRegExp(pattern) {
12
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
13
- return new RegExp(`^${escaped}$`);
11
+ function wildcardToRegExp(pattern, options = {}) {
12
+ let body = "";
13
+ for (let index = 0;index < pattern.length; index += 1) {
14
+ const char = pattern[index];
15
+ if (char === "*") {
16
+ if (pattern[index + 1] === "*") {
17
+ body += ".*";
18
+ index += 1;
19
+ } else {
20
+ body += options.segmentSafe ? "[^/]*" : ".*";
21
+ }
22
+ } else {
23
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
24
+ }
25
+ }
26
+ return new RegExp(`^${body}$`);
14
27
  }
15
- function matchString(value, matcher) {
28
+ function matchString(value, matcher, options = {}) {
16
29
  if (matcher === undefined)
17
30
  return true;
18
31
  if (value === undefined)
19
32
  return false;
20
33
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
21
- return matchers.some((item) => wildcardToRegExp(item).test(value));
34
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
22
35
  }
23
36
  function matchRecord(input, matcher) {
24
37
  if (!matcher)
25
38
  return true;
26
39
  return Object.entries(matcher).every(([path, expected]) => {
27
40
  const actual = getPathValue(input, path);
28
- if (typeof expected === "string" || Array.isArray(expected)) {
29
- return matchString(actual === undefined ? undefined : String(actual), expected);
30
- }
31
- return actual === expected;
41
+ return matchField(actual, expected, path);
32
42
  });
33
43
  }
44
+ function matchField(actual, expected, path) {
45
+ if (isNegativeMatcher(expected)) {
46
+ return !matchPositiveField(actual, expected.not, path);
47
+ }
48
+ return matchPositiveField(actual, expected, path);
49
+ }
50
+ function matchPositiveField(actual, expected, path) {
51
+ if (typeof expected === "string" || Array.isArray(expected)) {
52
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
53
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
54
+ }));
55
+ }
56
+ if (Array.isArray(actual)) {
57
+ return actual.some((item) => item === expected);
58
+ }
59
+ return actual === expected;
60
+ }
61
+ function stringCandidates(actual) {
62
+ if (actual === undefined)
63
+ return [];
64
+ if (Array.isArray(actual)) {
65
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
66
+ }
67
+ return [String(actual)];
68
+ }
69
+ function isPrimitiveFieldValue(value) {
70
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
71
+ }
72
+ function isNegativeMatcher(value) {
73
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
74
+ }
34
75
  function eventMatchesFilter(event, filter) {
35
76
  return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
36
77
  }
@@ -474,7 +515,7 @@ class EventsClient {
474
515
  }
475
516
  return deliveries;
476
517
  }
477
- async testChannel(id, input = {}) {
518
+ async matchChannel(id, input = {}) {
478
519
  const channel = await this.store.getChannel(id);
479
520
  if (!channel)
480
521
  throw new Error(`Channel not found: ${id}`);
@@ -491,6 +532,34 @@ class EventsClient {
491
532
  time: input.time,
492
533
  id: input.id
493
534
  });
535
+ const matched = channelMatchesEvent(channel, event);
536
+ return {
537
+ channelId: channel.id,
538
+ matched,
539
+ event,
540
+ filters: channel.filters,
541
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
542
+ };
543
+ }
544
+ async testChannel(id, input = {}, options = {}) {
545
+ const channel = await this.store.getChannel(id);
546
+ if (!channel)
547
+ throw new Error(`Channel not found: ${id}`);
548
+ const match = await this.matchChannel(id, input);
549
+ const event = match.event;
550
+ if (options.honorFilters && !match.matched) {
551
+ const timestamp = new Date().toISOString();
552
+ const result2 = createDeliveryResult(event, channel, [{
553
+ attempt: 1,
554
+ status: "skipped",
555
+ startedAt: timestamp,
556
+ completedAt: timestamp,
557
+ error: match.reason
558
+ }]);
559
+ result2.metadata = { reason: "filter_mismatch" };
560
+ await this.store.appendDelivery(result2);
561
+ return result2;
562
+ }
494
563
  const eventForChannel = await this.applyRedaction(event, channel);
495
564
  const result = await this.deliverWithRetry(eventForChannel, channel);
496
565
  await this.store.appendDelivery(result);
package/dist/types.d.ts CHANGED
@@ -27,13 +27,18 @@ export interface EventInput<TData extends EventData = EventData> {
27
27
  metadata?: Record<string, unknown>;
28
28
  }
29
29
  export type StringMatcher = string | string[];
30
+ export type FieldMatcherValue = StringMatcher | number | boolean | null;
31
+ export interface NegativeFieldMatcher {
32
+ not: FieldMatcherValue;
33
+ }
34
+ export type FieldMatcher = FieldMatcherValue | NegativeFieldMatcher;
30
35
  export interface EventFilter {
31
36
  source?: StringMatcher;
32
37
  type?: StringMatcher;
33
38
  subject?: StringMatcher;
34
39
  severity?: StringMatcher;
35
- data?: Record<string, StringMatcher | number | boolean | null>;
36
- metadata?: Record<string, StringMatcher | number | boolean | null>;
40
+ data?: Record<string, FieldMatcher>;
41
+ metadata?: Record<string, FieldMatcher>;
37
42
  }
38
43
  export interface RetryPolicy {
39
44
  maxAttempts?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/events",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Shared event envelopes, local subscriptions, and webhook delivery for Hasna open-source apps",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",