@hasna/events 0.1.7 → 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
@@ -219,6 +250,16 @@ events events replay --type ticket.created
219
250
  events events replay --dry-run
220
251
  ```
221
252
 
253
+ Machine-readable status:
254
+
255
+ ```bash
256
+ events status --json
257
+ ```
258
+
259
+ The status contract reports event, channel, delivery, file, and transport counts
260
+ only. It does not include event payloads, webhook signing secrets, command
261
+ environment values, or channel targets.
262
+
222
263
  Use `--json` for script-friendly output and `--dir <path>` for isolated data.
223
264
 
224
265
  ## App Integration Pattern
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
  });
@@ -62,6 +77,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
62
77
  function getEventsDataDir(override) {
63
78
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
64
79
  }
80
+ function getActiveEventsDirEnv() {
81
+ if (process.env[HASNA_EVENTS_DIR_ENV])
82
+ return HASNA_EVENTS_DIR_ENV;
83
+ if (process.env[HASNA_EVENTS_HOME_ENV])
84
+ return HASNA_EVENTS_HOME_ENV;
85
+ return null;
86
+ }
65
87
 
66
88
  class JsonEventsStore {
67
89
  dataDir;
@@ -174,6 +196,52 @@ class JsonEventsStore {
174
196
  });
175
197
  }
176
198
  }
199
+ async function getEventsStatus(dataDir) {
200
+ const store = new JsonEventsStore(dataDir);
201
+ await store.init();
202
+ const [channels, events, deliveries] = await Promise.all([
203
+ store.listChannels(),
204
+ store.listEvents(),
205
+ store.listDeliveries()
206
+ ]);
207
+ const transports = channels.reduce((counts, channel) => {
208
+ counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
209
+ return counts;
210
+ }, {});
211
+ return {
212
+ service: "events",
213
+ schemaVersion: "1.0",
214
+ dataDir: store.dataDir,
215
+ env: {
216
+ primary: HASNA_EVENTS_DIR_ENV,
217
+ fallback: HASNA_EVENTS_HOME_ENV,
218
+ active: getActiveEventsDirEnv()
219
+ },
220
+ files: {
221
+ channels: statusFile(store.dataDir, "channels.json", channels.length),
222
+ events: statusFile(store.dataDir, "events.json", events.length),
223
+ deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
224
+ },
225
+ counts: {
226
+ channels: channels.length,
227
+ enabledChannels: channels.filter((channel) => channel.enabled).length,
228
+ disabledChannels: channels.filter((channel) => !channel.enabled).length,
229
+ events: events.length,
230
+ deliveries: deliveries.length
231
+ },
232
+ transports,
233
+ safety: {
234
+ includesEventPayloads: false,
235
+ includesWebhookSecrets: false,
236
+ listOutputsRedactSecrets: true,
237
+ statusOutputIsMetadataOnly: true
238
+ }
239
+ };
240
+ }
241
+ function statusFile(dataDir, fileName, records) {
242
+ const path = join(dataDir, fileName);
243
+ return { path, exists: existsSync(path), records };
244
+ }
177
245
 
178
246
  // src/transports.ts
179
247
  import { randomUUID } from "crypto";
@@ -410,7 +478,7 @@ class EventsClient {
410
478
  }
411
479
  return deliveries;
412
480
  }
413
- async testChannel(id, input = {}) {
481
+ async matchChannel(id, input = {}) {
414
482
  const channel = await this.store.getChannel(id);
415
483
  if (!channel)
416
484
  throw new Error(`Channel not found: ${id}`);
@@ -427,6 +495,34 @@ class EventsClient {
427
495
  time: input.time,
428
496
  id: input.id
429
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
+ }
430
526
  const eventForChannel = await this.applyRedaction(event, channel);
431
527
  const result = await this.deliverWithRetry(eventForChannel, channel);
432
528
  await this.store.appendDelivery(result);
@@ -538,6 +634,61 @@ function normalizeRetryPolicy(policy) {
538
634
  };
539
635
  }
540
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
+
541
692
  // src/cli/index.ts
542
693
  function version() {
543
694
  try {
@@ -606,20 +757,16 @@ function parseJsonOption(value, fallback) {
606
757
  return parsed;
607
758
  }
608
759
  function parseFilter(args) {
609
- const filter2 = {};
610
- const type = takeOption(args, "--type") ?? takeOption(args, "--event-type");
611
- const source = takeOption(args, "--source");
612
- const subject = takeOption(args, "--subject");
613
- const severity = takeOption(args, "--severity");
614
- if (type)
615
- filter2.type = type;
616
- if (source)
617
- filter2.source = source;
618
- if (subject)
619
- filter2.subject = subject;
620
- if (severity)
621
- filter2.severity = severity;
622
- 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
+ });
623
770
  }
624
771
  function parseHeaders(values) {
625
772
  if (values.length === 0)
@@ -652,6 +799,9 @@ Usage:
652
799
  ${name} [--dir <path>] [--json] webhooks list
653
800
  ${name} [--dir <path>] [--json] webhooks remove <id>
654
801
  ${name} [--dir <path>] [--json] webhooks test <id>
802
+ ${name} [--dir <path>] [--json] webhooks match <id>
803
+ ${name} [--dir <path>] [--json] webhooks status
804
+ ${name} [--dir <path>] [--json] status
655
805
  ${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
656
806
  ${name} [--dir <path>] [--json] events list [--limit <n>]
657
807
  ${name} [--dir <path>] [--json] events replay [--id <event-id>] [--dry-run]
@@ -668,6 +818,8 @@ Usage:
668
818
  ${name} [--dir <path>] [--json] webhooks list
669
819
  ${name} [--dir <path>] [--json] webhooks remove <id>
670
820
  ${name} [--dir <path>] [--json] webhooks test <id>
821
+ ${name} [--dir <path>] [--json] webhooks match <id>
822
+ ${name} [--dir <path>] [--json] webhooks status
671
823
 
672
824
  Options:
673
825
  --id <id> Channel id for add
@@ -675,6 +827,11 @@ Options:
675
827
  --source <source> Event source filter
676
828
  --subject <subject> Event subject filter
677
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
678
835
  --transport <kind> webhook or command
679
836
  --secret <secret> Webhook signing secret
680
837
  --header <name=value> Webhook header, repeatable
@@ -712,6 +869,14 @@ async function runEventsCli(argv = process.argv.slice(2), options = {}) {
712
869
  console.log(version());
713
870
  return;
714
871
  }
872
+ if (group === "status") {
873
+ const status = await getEventsStatus(parsed.dir);
874
+ output(parsed, status, () => {
875
+ console.log(`events ${status.counts.events} event(s), ${status.counts.channels} channel(s), ${status.counts.deliveries} delivery record(s)`);
876
+ console.log(`dataDir: ${status.dataDir}`);
877
+ });
878
+ return;
879
+ }
715
880
  const store = new JsonEventsStore(parsed.dir);
716
881
  const client = new EventsClient({ store });
717
882
  if (group === "webhooks") {
@@ -795,6 +960,14 @@ async function handleWebhooks(client, command, tail, parsed, options) {
795
960
  });
796
961
  return;
797
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
+ }
798
971
  if (command === "remove") {
799
972
  const id = tail[0];
800
973
  if (!id)
@@ -808,15 +981,34 @@ async function handleWebhooks(client, command, tail, parsed, options) {
808
981
  const id = args.shift();
809
982
  if (!id)
810
983
  throw new Error("webhooks test requires a channel id");
984
+ const honorFilters = takeFlag(args, "--honor-filters");
811
985
  const result = await client.testChannel(id, {
812
986
  source: takeOption(args, "--source") ?? options.source ?? "hasna.events",
813
987
  type: takeOption(args, "--type") ?? "events.test",
814
988
  subject: takeOption(args, "--subject") ?? id,
815
- data: parseJsonOption(takeOption(args, "--data"), { test: true })
816
- });
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 });
817
993
  output(parsed, result, () => console.log(`${result.status}: ${result.channelId}`));
818
994
  return;
819
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
+ }
820
1012
  throw new Error(`Unknown webhooks command: ${command ?? ""}`);
821
1013
  }
822
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
  });
@@ -52,6 +67,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
52
67
  function getEventsDataDir(override) {
53
68
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
54
69
  }
70
+ function getActiveEventsDirEnv() {
71
+ if (process.env[HASNA_EVENTS_DIR_ENV])
72
+ return HASNA_EVENTS_DIR_ENV;
73
+ if (process.env[HASNA_EVENTS_HOME_ENV])
74
+ return HASNA_EVENTS_HOME_ENV;
75
+ return null;
76
+ }
55
77
 
56
78
  class JsonEventsStore {
57
79
  dataDir;
@@ -164,6 +186,52 @@ class JsonEventsStore {
164
186
  });
165
187
  }
166
188
  }
189
+ async function getEventsStatus(dataDir) {
190
+ const store = new JsonEventsStore(dataDir);
191
+ await store.init();
192
+ const [channels, events, deliveries] = await Promise.all([
193
+ store.listChannels(),
194
+ store.listEvents(),
195
+ store.listDeliveries()
196
+ ]);
197
+ const transports = channels.reduce((counts, channel) => {
198
+ counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
199
+ return counts;
200
+ }, {});
201
+ return {
202
+ service: "events",
203
+ schemaVersion: "1.0",
204
+ dataDir: store.dataDir,
205
+ env: {
206
+ primary: HASNA_EVENTS_DIR_ENV,
207
+ fallback: HASNA_EVENTS_HOME_ENV,
208
+ active: getActiveEventsDirEnv()
209
+ },
210
+ files: {
211
+ channels: statusFile(store.dataDir, "channels.json", channels.length),
212
+ events: statusFile(store.dataDir, "events.json", events.length),
213
+ deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
214
+ },
215
+ counts: {
216
+ channels: channels.length,
217
+ enabledChannels: channels.filter((channel) => channel.enabled).length,
218
+ disabledChannels: channels.filter((channel) => !channel.enabled).length,
219
+ events: events.length,
220
+ deliveries: deliveries.length
221
+ },
222
+ transports,
223
+ safety: {
224
+ includesEventPayloads: false,
225
+ includesWebhookSecrets: false,
226
+ listOutputsRedactSecrets: true,
227
+ statusOutputIsMetadataOnly: true
228
+ }
229
+ };
230
+ }
231
+ function statusFile(dataDir, fileName, records) {
232
+ const path = join(dataDir, fileName);
233
+ return { path, exists: existsSync(path), records };
234
+ }
167
235
 
168
236
  // src/signing.ts
169
237
  import { createHmac, timingSafeEqual } from "crypto";
@@ -421,7 +489,7 @@ class EventsClient {
421
489
  }
422
490
  return deliveries;
423
491
  }
424
- async testChannel(id, input = {}) {
492
+ async matchChannel(id, input = {}) {
425
493
  const channel = await this.store.getChannel(id);
426
494
  if (!channel)
427
495
  throw new Error(`Channel not found: ${id}`);
@@ -438,6 +506,34 @@ class EventsClient {
438
506
  time: input.time,
439
507
  id: input.id
440
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
+ }
441
537
  const eventForChannel = await this.applyRedaction(event, channel);
442
538
  const result = await this.deliverWithRetry(eventForChannel, channel);
443
539
  await this.store.appendDelivery(result);
@@ -549,6 +645,61 @@ function normalizeRetryPolicy(policy) {
549
645
  };
550
646
  }
551
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
+
552
703
  // src/commander.ts
553
704
  function parseJsonObject(value, fallback) {
554
705
  if (!value)
@@ -571,18 +722,6 @@ function parseHeaders(values) {
571
722
  }
572
723
  return headers;
573
724
  }
574
- function parseFilter(options) {
575
- const filter2 = {};
576
- if (options.source)
577
- filter2.source = options.source;
578
- if (options.type)
579
- filter2.type = options.type;
580
- if (options.subject)
581
- filter2.subject = options.subject;
582
- if (options.severity)
583
- filter2.severity = options.severity;
584
- return Object.keys(filter2).length > 0 ? [filter2] : undefined;
585
- }
586
725
  function createClient(options) {
587
726
  if (options.createClient)
588
727
  return options.createClient();
@@ -602,14 +741,14 @@ function wantsJson(actionOptions, command) {
602
741
  }
603
742
  function registerWebhookCommands(program, options) {
604
743
  const webhooks = program.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
605
- 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) => {
606
745
  const timestamp = new Date().toISOString();
607
746
  const channel = {
608
747
  id: actionOptions.id,
609
748
  name: actionOptions.name,
610
749
  enabled: !actionOptions.disabled,
611
750
  transport: actionOptions.transport,
612
- filters: parseFilter(actionOptions),
751
+ filters: parseFilterOptions(actionOptions),
613
752
  retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
614
753
  redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
615
754
  createdAt: timestamp,
@@ -639,20 +778,36 @@ function registerWebhookCommands(program, options) {
639
778
  console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
640
779
  }
641
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
+ });
642
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) => {
643
786
  const removed = await createClient(options).removeChannel(id);
644
787
  print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
645
788
  });
646
- 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) => {
647
790
  const result = await createClient(options).testChannel(id, {
648
- source: options.source,
791
+ source: actionOptions.source ?? options.source,
649
792
  type: actionOptions.type,
650
793
  subject: actionOptions.subject ?? id,
651
794
  message: actionOptions.message,
652
- data: parseJsonObject(actionOptions.data, { test: true })
653
- });
795
+ data: parseJsonObject(actionOptions.data, { test: true }),
796
+ metadata: parseJsonObject(actionOptions.metadata, {})
797
+ }, { honorFilters: actionOptions.honorFilters });
654
798
  print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
655
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
+ });
656
811
  return webhooks;
657
812
  }
658
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
  });
@@ -52,6 +67,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
52
67
  function getEventsDataDir(override) {
53
68
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
54
69
  }
70
+ function getActiveEventsDirEnv() {
71
+ if (process.env[HASNA_EVENTS_DIR_ENV])
72
+ return HASNA_EVENTS_DIR_ENV;
73
+ if (process.env[HASNA_EVENTS_HOME_ENV])
74
+ return HASNA_EVENTS_HOME_ENV;
75
+ return null;
76
+ }
55
77
 
56
78
  class JsonEventsStore {
57
79
  dataDir;
@@ -164,6 +186,52 @@ class JsonEventsStore {
164
186
  });
165
187
  }
166
188
  }
189
+ async function getEventsStatus(dataDir) {
190
+ const store = new JsonEventsStore(dataDir);
191
+ await store.init();
192
+ const [channels, events, deliveries] = await Promise.all([
193
+ store.listChannels(),
194
+ store.listEvents(),
195
+ store.listDeliveries()
196
+ ]);
197
+ const transports = channels.reduce((counts, channel) => {
198
+ counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
199
+ return counts;
200
+ }, {});
201
+ return {
202
+ service: "events",
203
+ schemaVersion: "1.0",
204
+ dataDir: store.dataDir,
205
+ env: {
206
+ primary: HASNA_EVENTS_DIR_ENV,
207
+ fallback: HASNA_EVENTS_HOME_ENV,
208
+ active: getActiveEventsDirEnv()
209
+ },
210
+ files: {
211
+ channels: statusFile(store.dataDir, "channels.json", channels.length),
212
+ events: statusFile(store.dataDir, "events.json", events.length),
213
+ deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
214
+ },
215
+ counts: {
216
+ channels: channels.length,
217
+ enabledChannels: channels.filter((channel) => channel.enabled).length,
218
+ disabledChannels: channels.filter((channel) => !channel.enabled).length,
219
+ events: events.length,
220
+ deliveries: deliveries.length
221
+ },
222
+ transports,
223
+ safety: {
224
+ includesEventPayloads: false,
225
+ includesWebhookSecrets: false,
226
+ listOutputsRedactSecrets: true,
227
+ statusOutputIsMetadataOnly: true
228
+ }
229
+ };
230
+ }
231
+ function statusFile(dataDir, fileName, records) {
232
+ const path = join(dataDir, fileName);
233
+ return { path, exists: existsSync(path), records };
234
+ }
167
235
 
168
236
  // src/signing.ts
169
237
  import { createHmac, timingSafeEqual } from "crypto";
@@ -421,7 +489,7 @@ class EventsClient {
421
489
  }
422
490
  return deliveries;
423
491
  }
424
- async testChannel(id, input = {}) {
492
+ async matchChannel(id, input = {}) {
425
493
  const channel = await this.store.getChannel(id);
426
494
  if (!channel)
427
495
  throw new Error(`Channel not found: ${id}`);
@@ -438,6 +506,34 @@ class EventsClient {
438
506
  time: input.time,
439
507
  id: input.id
440
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
+ }
441
537
  const eventForChannel = await this.applyRedaction(event, channel);
442
538
  const result = await this.deliverWithRetry(eventForChannel, channel);
443
539
  await this.store.appendDelivery(result);
@@ -558,7 +654,9 @@ export {
558
654
  redactPaths,
559
655
  matchString,
560
656
  isTimestampWithinTolerance,
657
+ getEventsStatus,
561
658
  getEventsDataDir,
659
+ getActiveEventsDirEnv,
562
660
  eventMatchesFilter,
563
661
  dispatchWebhook,
564
662
  dispatchCommand,
package/dist/storage.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { ChannelConfig, DeliveryResult, EventEnvelope, StoredEventsData } from "./types.js";
1
+ import type { ChannelConfig, DeliveryResult, EventEnvelope, EventsStatus, StoredEventsData } from "./types.js";
2
2
  export declare const HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
3
3
  export declare const HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
4
4
  export declare function getEventsDataDir(override?: string): string;
5
+ export declare function getActiveEventsDirEnv(): EventsStatus["env"]["active"];
5
6
  export interface EventsStore {
6
7
  dataDir: string;
7
8
  init(): Promise<void>;
@@ -42,3 +43,4 @@ export declare class JsonEventsStore implements EventsStore {
42
43
  private readJson;
43
44
  private writeJson;
44
45
  }
46
+ export declare function getEventsStatus(dataDir?: string): Promise<EventsStatus>;
package/dist/storage.js CHANGED
@@ -9,6 +9,13 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
9
9
  function getEventsDataDir(override) {
10
10
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
11
11
  }
12
+ function getActiveEventsDirEnv() {
13
+ if (process.env[HASNA_EVENTS_DIR_ENV])
14
+ return HASNA_EVENTS_DIR_ENV;
15
+ if (process.env[HASNA_EVENTS_HOME_ENV])
16
+ return HASNA_EVENTS_HOME_ENV;
17
+ return null;
18
+ }
12
19
 
13
20
  class JsonEventsStore {
14
21
  dataDir;
@@ -121,8 +128,56 @@ class JsonEventsStore {
121
128
  });
122
129
  }
123
130
  }
131
+ async function getEventsStatus(dataDir) {
132
+ const store = new JsonEventsStore(dataDir);
133
+ await store.init();
134
+ const [channels, events, deliveries] = await Promise.all([
135
+ store.listChannels(),
136
+ store.listEvents(),
137
+ store.listDeliveries()
138
+ ]);
139
+ const transports = channels.reduce((counts, channel) => {
140
+ counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
141
+ return counts;
142
+ }, {});
143
+ return {
144
+ service: "events",
145
+ schemaVersion: "1.0",
146
+ dataDir: store.dataDir,
147
+ env: {
148
+ primary: HASNA_EVENTS_DIR_ENV,
149
+ fallback: HASNA_EVENTS_HOME_ENV,
150
+ active: getActiveEventsDirEnv()
151
+ },
152
+ files: {
153
+ channels: statusFile(store.dataDir, "channels.json", channels.length),
154
+ events: statusFile(store.dataDir, "events.json", events.length),
155
+ deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
156
+ },
157
+ counts: {
158
+ channels: channels.length,
159
+ enabledChannels: channels.filter((channel) => channel.enabled).length,
160
+ disabledChannels: channels.filter((channel) => !channel.enabled).length,
161
+ events: events.length,
162
+ deliveries: deliveries.length
163
+ },
164
+ transports,
165
+ safety: {
166
+ includesEventPayloads: false,
167
+ includesWebhookSecrets: false,
168
+ listOutputsRedactSecrets: true,
169
+ statusOutputIsMetadataOnly: true
170
+ }
171
+ };
172
+ }
173
+ function statusFile(dataDir, fileName, records) {
174
+ const path = join(dataDir, fileName);
175
+ return { path, exists: existsSync(path), records };
176
+ }
124
177
  export {
178
+ getEventsStatus,
125
179
  getEventsDataDir,
180
+ getActiveEventsDirEnv,
126
181
  JsonEventsStore,
127
182
  HASNA_EVENTS_HOME_ENV,
128
183
  HASNA_EVENTS_DIR_ENV
package/dist/types.d.ts CHANGED
@@ -117,3 +117,44 @@ export interface EmitResult<TData extends EventData = EventData> {
117
117
  deliveries: DeliveryResult[];
118
118
  deduped: boolean;
119
119
  }
120
+ export interface EventsStatus {
121
+ service: "events";
122
+ schemaVersion: "1.0";
123
+ dataDir: string;
124
+ env: {
125
+ primary: "HASNA_EVENTS_DIR";
126
+ fallback: "HASNA_EVENTS_HOME";
127
+ active: "HASNA_EVENTS_DIR" | "HASNA_EVENTS_HOME" | null;
128
+ };
129
+ files: {
130
+ channels: {
131
+ path: string;
132
+ exists: boolean;
133
+ records: number;
134
+ };
135
+ events: {
136
+ path: string;
137
+ exists: boolean;
138
+ records: number;
139
+ };
140
+ deliveries: {
141
+ path: string;
142
+ exists: boolean;
143
+ records: number;
144
+ };
145
+ };
146
+ counts: {
147
+ channels: number;
148
+ enabledChannels: number;
149
+ disabledChannels: number;
150
+ events: number;
151
+ deliveries: number;
152
+ };
153
+ transports: Record<string, number>;
154
+ safety: {
155
+ includesEventPayloads: false;
156
+ includesWebhookSecrets: false;
157
+ listOutputsRedactSecrets: true;
158
+ statusOutputIsMetadataOnly: true;
159
+ };
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/events",
3
- "version": "0.1.7",
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",