@hasna/events 0.1.8 → 0.1.9

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,37 @@ 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
+ and array traversal is not special-cased. Wildcard behavior stays broad for
212
+ legacy source/type/subject filters. For field paths ending in `_path` or `.path`,
213
+ `*` matches one path segment and `**` matches recursively.
214
+
215
+ ```bash
216
+ events webhooks add loops \
217
+ --id open-source-task-route \
218
+ --transport command \
219
+ --source todos \
220
+ --type task.created \
221
+ --metadata 'project_path=/home/hasna/workspace/hasna/opensource/*' \
222
+ --metadata-json 'route_enabled=true' \
223
+ --arg events \
224
+ --arg handle \
225
+ --arg todos-task
226
+
227
+ events webhooks match open-source-task-route \
228
+ --source todos \
229
+ --type task.created \
230
+ --metadata '{"project_path":"/home/hasna/workspace/hasna/opensource/open-events","route_enabled":true}'
231
+
232
+ events webhooks test open-source-task-route --honor-filters \
233
+ --source todos \
234
+ --type task.created \
235
+ --metadata '{"project_path":"/tmp/outside","route_enabled":true}'
236
+ ```
237
+
207
238
  Emit, list, and replay:
208
239
 
209
240
  ```bash
package/dist/cli/index.js CHANGED
@@ -18,17 +18,30 @@ 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)
@@ -36,7 +49,9 @@ function matchRecord(input, matcher) {
36
49
  return Object.entries(matcher).every(([path, expected]) => {
37
50
  const actual = getPathValue(input, path);
38
51
  if (typeof expected === "string" || Array.isArray(expected)) {
39
- return matchString(actual === undefined ? undefined : String(actual), expected);
52
+ return matchString(actual === undefined ? undefined : String(actual), expected, {
53
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
54
+ });
40
55
  }
41
56
  return actual === expected;
42
57
  });
@@ -463,7 +478,7 @@ class EventsClient {
463
478
  }
464
479
  return deliveries;
465
480
  }
466
- async testChannel(id, input = {}) {
481
+ async matchChannel(id, input = {}) {
467
482
  const channel = await this.store.getChannel(id);
468
483
  if (!channel)
469
484
  throw new Error(`Channel not found: ${id}`);
@@ -480,6 +495,34 @@ class EventsClient {
480
495
  time: input.time,
481
496
  id: input.id
482
497
  });
498
+ const matched = channelMatchesEvent(channel, event);
499
+ return {
500
+ channelId: channel.id,
501
+ matched,
502
+ event,
503
+ filters: channel.filters,
504
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
505
+ };
506
+ }
507
+ async testChannel(id, input = {}, options = {}) {
508
+ const channel = await this.store.getChannel(id);
509
+ if (!channel)
510
+ throw new Error(`Channel not found: ${id}`);
511
+ const match = await this.matchChannel(id, input);
512
+ const event = match.event;
513
+ if (options.honorFilters && !match.matched) {
514
+ const timestamp = new Date().toISOString();
515
+ const result2 = createDeliveryResult(event, channel, [{
516
+ attempt: 1,
517
+ status: "skipped",
518
+ startedAt: timestamp,
519
+ completedAt: timestamp,
520
+ error: match.reason
521
+ }]);
522
+ result2.metadata = { reason: "filter_mismatch" };
523
+ await this.store.appendDelivery(result2);
524
+ return result2;
525
+ }
483
526
  const eventForChannel = await this.applyRedaction(event, channel);
484
527
  const result = await this.deliverWithRetry(eventForChannel, channel);
485
528
  await this.store.appendDelivery(result);
@@ -591,6 +634,61 @@ function normalizeRetryPolicy(policy) {
591
634
  };
592
635
  }
593
636
 
637
+ // src/filter-options.ts
638
+ function parseFieldMatchers(values, label, typed = false) {
639
+ if (!values?.length)
640
+ return;
641
+ const result = {};
642
+ for (const value of values) {
643
+ const separator = value.indexOf("=");
644
+ if (separator <= 0)
645
+ throw new Error(`Invalid ${label} filter, expected path=value: ${value}`);
646
+ const path = value.slice(0, separator);
647
+ if (path in result)
648
+ throw new Error(`Duplicate ${label} filter path: ${path}`);
649
+ result[path] = typed ? parseTypedMatcherValue(value.slice(separator + 1), label) : value.slice(separator + 1);
650
+ }
651
+ return result;
652
+ }
653
+ function parseFilterOptions(options) {
654
+ const filter2 = {};
655
+ if (options.source)
656
+ filter2.source = options.source;
657
+ if (options.type)
658
+ filter2.type = options.type;
659
+ if (options.subject)
660
+ filter2.subject = options.subject;
661
+ if (options.severity)
662
+ filter2.severity = options.severity;
663
+ const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
664
+ const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
665
+ if (Object.keys(data).length > 0)
666
+ filter2.data = data;
667
+ if (Object.keys(metadata).length > 0)
668
+ filter2.metadata = metadata;
669
+ return Object.keys(filter2).length > 0 ? [filter2] : undefined;
670
+ }
671
+ function mergeMatchers(...records) {
672
+ const result = {};
673
+ for (const record of records) {
674
+ if (!record)
675
+ continue;
676
+ for (const [path, value] of Object.entries(record)) {
677
+ if (path in result)
678
+ throw new Error(`Duplicate filter path: ${path}`);
679
+ result[path] = value;
680
+ }
681
+ }
682
+ return result;
683
+ }
684
+ function parseTypedMatcherValue(value, label) {
685
+ const parsed = JSON.parse(value);
686
+ if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
687
+ return parsed;
688
+ }
689
+ throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
690
+ }
691
+
594
692
  // src/cli/index.ts
595
693
  function version() {
596
694
  try {
@@ -659,20 +757,16 @@ function parseJsonOption(value, fallback) {
659
757
  return parsed;
660
758
  }
661
759
  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;
760
+ return parseFilterOptions({
761
+ type: takeOption(args, "--type") ?? takeOption(args, "--event-type"),
762
+ source: takeOption(args, "--source"),
763
+ subject: takeOption(args, "--subject"),
764
+ severity: takeOption(args, "--severity"),
765
+ data: takeMany(args, "--data"),
766
+ metadata: takeMany(args, "--metadata"),
767
+ dataJson: takeMany(args, "--data-json"),
768
+ metadataJson: takeMany(args, "--metadata-json")
769
+ });
676
770
  }
677
771
  function parseHeaders(values) {
678
772
  if (values.length === 0)
@@ -705,6 +799,8 @@ Usage:
705
799
  ${name} [--dir <path>] [--json] webhooks list
706
800
  ${name} [--dir <path>] [--json] webhooks remove <id>
707
801
  ${name} [--dir <path>] [--json] webhooks test <id>
802
+ ${name} [--dir <path>] [--json] webhooks match <id>
803
+ ${name} [--dir <path>] [--json] webhooks status
708
804
  ${name} [--dir <path>] [--json] status
709
805
  ${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
710
806
  ${name} [--dir <path>] [--json] events list [--limit <n>]
@@ -722,6 +818,8 @@ Usage:
722
818
  ${name} [--dir <path>] [--json] webhooks list
723
819
  ${name} [--dir <path>] [--json] webhooks remove <id>
724
820
  ${name} [--dir <path>] [--json] webhooks test <id>
821
+ ${name} [--dir <path>] [--json] webhooks match <id>
822
+ ${name} [--dir <path>] [--json] webhooks status
725
823
 
726
824
  Options:
727
825
  --id <id> Channel id for add
@@ -729,6 +827,11 @@ Options:
729
827
  --source <source> Event source filter
730
828
  --subject <subject> Event subject filter
731
829
  --severity <severity> Event severity filter
830
+ --data <path=value> Event data field filter, repeatable; string values, dot paths, * segment wildcard, ** recursive wildcard
831
+ --metadata <path=value> Event metadata field filter, repeatable; string values, dot paths, * segment wildcard, ** recursive wildcard
832
+ --data-json <path=json> Event data field filter with typed JSON value
833
+ --metadata-json <path=json> Event metadata field filter with typed JSON value
834
+ --honor-filters On webhooks test, skip delivery when the sample event does not match filters
732
835
  --transport <kind> webhook or command
733
836
  --secret <secret> Webhook signing secret
734
837
  --header <name=value> Webhook header, repeatable
@@ -857,6 +960,14 @@ async function handleWebhooks(client, command, tail, parsed, options) {
857
960
  });
858
961
  return;
859
962
  }
963
+ if (command === "status") {
964
+ const status = await getEventsStatus(parsed.dir);
965
+ output(parsed, status, () => {
966
+ console.log(`events dataDir: ${status.dataDir}`);
967
+ console.log(`${status.counts.enabledChannels}/${status.counts.channels} channel(s) enabled`);
968
+ });
969
+ return;
970
+ }
860
971
  if (command === "remove") {
861
972
  const id = tail[0];
862
973
  if (!id)
@@ -870,15 +981,34 @@ async function handleWebhooks(client, command, tail, parsed, options) {
870
981
  const id = args.shift();
871
982
  if (!id)
872
983
  throw new Error("webhooks test requires a channel id");
984
+ const honorFilters = takeFlag(args, "--honor-filters");
873
985
  const result = await client.testChannel(id, {
874
986
  source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
875
987
  type: takeOption(args, "--type") ?? "events.test",
876
988
  subject: takeOption(args, "--subject") ?? id,
877
- data: parseJsonOption(takeOption(args, "--data"), { test: true })
878
- });
989
+ message: takeOption(args, "--message") ?? "Hasna events test delivery",
990
+ data: parseJsonOption(takeOption(args, "--data"), { test: true }),
991
+ metadata: parseJsonOption(takeOption(args, "--metadata"), {})
992
+ }, { honorFilters });
879
993
  output(parsed, result, () => console.log(`${result.status}: ${result.channelId}`));
880
994
  return;
881
995
  }
996
+ if (command === "match") {
997
+ const args = [...tail];
998
+ const id = args.shift();
999
+ if (!id)
1000
+ throw new Error("webhooks match requires a channel id");
1001
+ const result = await client.matchChannel(id, {
1002
+ source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
1003
+ type: takeOption(args, "--type") ?? "events.test",
1004
+ subject: takeOption(args, "--subject") ?? id,
1005
+ message: takeOption(args, "--message") ?? "Hasna events match preview",
1006
+ data: parseJsonOption(takeOption(args, "--data"), { test: true }),
1007
+ metadata: parseJsonOption(takeOption(args, "--metadata"), {})
1008
+ });
1009
+ output(parsed, result, () => console.log(`${result.matched ? "matched" : "skipped"}: ${result.channelId}`));
1010
+ return;
1011
+ }
882
1012
  throw new Error(`Unknown webhooks command: ${command ?? ""}`);
883
1013
  }
884
1014
  async function handleEvents(client, command, tail, parsed, options) {
package/dist/commander.js CHANGED
@@ -8,17 +8,30 @@ 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)
@@ -26,7 +39,9 @@ function matchRecord(input, matcher) {
26
39
  return Object.entries(matcher).every(([path, expected]) => {
27
40
  const actual = getPathValue(input, path);
28
41
  if (typeof expected === "string" || Array.isArray(expected)) {
29
- return matchString(actual === undefined ? undefined : String(actual), expected);
42
+ return matchString(actual === undefined ? undefined : String(actual), expected, {
43
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
44
+ });
30
45
  }
31
46
  return actual === expected;
32
47
  });
@@ -474,7 +489,7 @@ class EventsClient {
474
489
  }
475
490
  return deliveries;
476
491
  }
477
- async testChannel(id, input = {}) {
492
+ async matchChannel(id, input = {}) {
478
493
  const channel = await this.store.getChannel(id);
479
494
  if (!channel)
480
495
  throw new Error(`Channel not found: ${id}`);
@@ -491,6 +506,34 @@ class EventsClient {
491
506
  time: input.time,
492
507
  id: input.id
493
508
  });
509
+ const matched = channelMatchesEvent(channel, event);
510
+ return {
511
+ channelId: channel.id,
512
+ matched,
513
+ event,
514
+ filters: channel.filters,
515
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
516
+ };
517
+ }
518
+ async testChannel(id, input = {}, options = {}) {
519
+ const channel = await this.store.getChannel(id);
520
+ if (!channel)
521
+ throw new Error(`Channel not found: ${id}`);
522
+ const match = await this.matchChannel(id, input);
523
+ const event = match.event;
524
+ if (options.honorFilters && !match.matched) {
525
+ const timestamp = new Date().toISOString();
526
+ const result2 = createDeliveryResult(event, channel, [{
527
+ attempt: 1,
528
+ status: "skipped",
529
+ startedAt: timestamp,
530
+ completedAt: timestamp,
531
+ error: match.reason
532
+ }]);
533
+ result2.metadata = { reason: "filter_mismatch" };
534
+ await this.store.appendDelivery(result2);
535
+ return result2;
536
+ }
494
537
  const eventForChannel = await this.applyRedaction(event, channel);
495
538
  const result = await this.deliverWithRetry(eventForChannel, channel);
496
539
  await this.store.appendDelivery(result);
@@ -602,6 +645,61 @@ function normalizeRetryPolicy(policy) {
602
645
  };
603
646
  }
604
647
 
648
+ // src/filter-options.ts
649
+ function parseFieldMatchers(values, label, typed = false) {
650
+ if (!values?.length)
651
+ return;
652
+ const result = {};
653
+ for (const value of values) {
654
+ const separator = value.indexOf("=");
655
+ if (separator <= 0)
656
+ throw new Error(`Invalid ${label} filter, expected path=value: ${value}`);
657
+ const path = value.slice(0, separator);
658
+ if (path in result)
659
+ throw new Error(`Duplicate ${label} filter path: ${path}`);
660
+ result[path] = typed ? parseTypedMatcherValue(value.slice(separator + 1), label) : value.slice(separator + 1);
661
+ }
662
+ return result;
663
+ }
664
+ function parseFilterOptions(options) {
665
+ const filter2 = {};
666
+ if (options.source)
667
+ filter2.source = options.source;
668
+ if (options.type)
669
+ filter2.type = options.type;
670
+ if (options.subject)
671
+ filter2.subject = options.subject;
672
+ if (options.severity)
673
+ filter2.severity = options.severity;
674
+ const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
675
+ const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
676
+ if (Object.keys(data).length > 0)
677
+ filter2.data = data;
678
+ if (Object.keys(metadata).length > 0)
679
+ filter2.metadata = metadata;
680
+ return Object.keys(filter2).length > 0 ? [filter2] : undefined;
681
+ }
682
+ function mergeMatchers(...records) {
683
+ const result = {};
684
+ for (const record of records) {
685
+ if (!record)
686
+ continue;
687
+ for (const [path, value] of Object.entries(record)) {
688
+ if (path in result)
689
+ throw new Error(`Duplicate filter path: ${path}`);
690
+ result[path] = value;
691
+ }
692
+ }
693
+ return result;
694
+ }
695
+ function parseTypedMatcherValue(value, label) {
696
+ const parsed = JSON.parse(value);
697
+ if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
698
+ return parsed;
699
+ }
700
+ throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
701
+ }
702
+
605
703
  // src/commander.ts
606
704
  function parseJsonObject(value, fallback) {
607
705
  if (!value)
@@ -624,18 +722,6 @@ function parseHeaders(values) {
624
722
  }
625
723
  return headers;
626
724
  }
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
725
  function createClient(options) {
640
726
  if (options.createClient)
641
727
  return options.createClient();
@@ -655,14 +741,14 @@ function wantsJson(actionOptions, command) {
655
741
  }
656
742
  function registerWebhookCommands(program, options) {
657
743
  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) => {
744
+ 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, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--metadata <path=value...>", "Event metadata field filter; string values, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--data-json <path=json...>", "Event data field filter with typed JSON value", collectValues, []).option("--metadata-json <path=json...>", "Event metadata field filter with typed JSON value", 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
745
  const timestamp = new Date().toISOString();
660
746
  const channel = {
661
747
  id: actionOptions.id,
662
748
  name: actionOptions.name,
663
749
  enabled: !actionOptions.disabled,
664
750
  transport: actionOptions.transport,
665
- filters: parseFilter(actionOptions),
751
+ filters: parseFilterOptions(actionOptions),
666
752
  retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
667
753
  redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
668
754
  createdAt: timestamp,
@@ -692,20 +778,36 @@ function registerWebhookCommands(program, options) {
692
778
  console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
693
779
  }
694
780
  });
781
+ webhooks.command("status").description("Show events webhook storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
782
+ const status = await getEventsStatus(options.dataDir);
783
+ print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
784
+ });
695
785
  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
786
  const removed = await createClient(options).removeChannel(id);
697
787
  print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
698
788
  });
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) => {
789
+ 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
790
  const result = await createClient(options).testChannel(id, {
701
- source: options.source,
791
+ source: actionOptions.source ?? options.source,
702
792
  type: actionOptions.type,
703
793
  subject: actionOptions.subject ?? id,
704
794
  message: actionOptions.message,
705
- data: parseJsonObject(actionOptions.data, { test: true })
706
- });
795
+ data: parseJsonObject(actionOptions.data, { test: true }),
796
+ metadata: parseJsonObject(actionOptions.metadata, {})
797
+ }, { honorFilters: actionOptions.honorFilters });
707
798
  print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
708
799
  });
800
+ 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) => {
801
+ const result = await createClient(options).matchChannel(id, {
802
+ source: actionOptions.source ?? options.source,
803
+ type: actionOptions.type,
804
+ subject: actionOptions.subject ?? id,
805
+ message: actionOptions.message,
806
+ data: parseJsonObject(actionOptions.data, { test: true }),
807
+ metadata: parseJsonObject(actionOptions.metadata, {})
808
+ });
809
+ print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
810
+ });
709
811
  return webhooks;
710
812
  }
711
813
  function registerEventCommands(program, options) {
@@ -0,0 +1,15 @@
1
+ import type { EventFilter, StringMatcher } from "./types.js";
2
+ type MatcherValue = StringMatcher | number | boolean | null;
3
+ export interface FilterOptionInput {
4
+ source?: string;
5
+ type?: string;
6
+ subject?: string;
7
+ severity?: string;
8
+ data?: string[];
9
+ metadata?: string[];
10
+ dataJson?: string[];
11
+ metadataJson?: string[];
12
+ }
13
+ export declare function parseFieldMatchers(values: string[] | undefined, label: string, typed?: boolean): Record<string, MatcherValue> | undefined;
14
+ export declare function parseFilterOptions(options: FilterOptionInput): EventFilter[] | undefined;
15
+ 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,17 +8,30 @@ 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)
@@ -26,7 +39,9 @@ function matchRecord(input, matcher) {
26
39
  return Object.entries(matcher).every(([path, expected]) => {
27
40
  const actual = getPathValue(input, path);
28
41
  if (typeof expected === "string" || Array.isArray(expected)) {
29
- return matchString(actual === undefined ? undefined : String(actual), expected);
42
+ return matchString(actual === undefined ? undefined : String(actual), expected, {
43
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
44
+ });
30
45
  }
31
46
  return actual === expected;
32
47
  });
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,17 +8,30 @@ 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)
@@ -26,7 +39,9 @@ function matchRecord(input, matcher) {
26
39
  return Object.entries(matcher).every(([path, expected]) => {
27
40
  const actual = getPathValue(input, path);
28
41
  if (typeof expected === "string" || Array.isArray(expected)) {
29
- return matchString(actual === undefined ? undefined : String(actual), expected);
42
+ return matchString(actual === undefined ? undefined : String(actual), expected, {
43
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
44
+ });
30
45
  }
31
46
  return actual === expected;
32
47
  });
@@ -474,7 +489,7 @@ class EventsClient {
474
489
  }
475
490
  return deliveries;
476
491
  }
477
- async testChannel(id, input = {}) {
492
+ async matchChannel(id, input = {}) {
478
493
  const channel = await this.store.getChannel(id);
479
494
  if (!channel)
480
495
  throw new Error(`Channel not found: ${id}`);
@@ -491,6 +506,34 @@ class EventsClient {
491
506
  time: input.time,
492
507
  id: input.id
493
508
  });
509
+ const matched = channelMatchesEvent(channel, event);
510
+ return {
511
+ channelId: channel.id,
512
+ matched,
513
+ event,
514
+ filters: channel.filters,
515
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
516
+ };
517
+ }
518
+ async testChannel(id, input = {}, options = {}) {
519
+ const channel = await this.store.getChannel(id);
520
+ if (!channel)
521
+ throw new Error(`Channel not found: ${id}`);
522
+ const match = await this.matchChannel(id, input);
523
+ const event = match.event;
524
+ if (options.honorFilters && !match.matched) {
525
+ const timestamp = new Date().toISOString();
526
+ const result2 = createDeliveryResult(event, channel, [{
527
+ attempt: 1,
528
+ status: "skipped",
529
+ startedAt: timestamp,
530
+ completedAt: timestamp,
531
+ error: match.reason
532
+ }]);
533
+ result2.metadata = { reason: "filter_mismatch" };
534
+ await this.store.appendDelivery(result2);
535
+ return result2;
536
+ }
494
537
  const eventForChannel = await this.applyRedaction(event, channel);
495
538
  const result = await this.deliverWithRetry(eventForChannel, channel);
496
539
  await this.store.appendDelivery(result);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/events",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",