@hasna/events 0.1.13 → 0.1.14

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/dist/commander.js CHANGED
@@ -98,11 +98,15 @@ function channelMatchesEvent(channel, event) {
98
98
 
99
99
  // src/storage.ts
100
100
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
101
+ import { Buffer as Buffer2 } from "buffer";
101
102
  import { existsSync } from "fs";
102
103
  import { homedir } from "os";
103
104
  import { join } from "path";
104
105
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
105
106
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
107
+ var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
108
+ var DEFAULT_EVENT_PAGE_LIMIT = 100;
109
+ var MAX_EVENT_PAGE_LIMIT = 1000;
106
110
  function getEventsDataDir(override) {
107
111
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
108
112
  }
@@ -116,11 +120,13 @@ function getActiveEventsDirEnv() {
116
120
 
117
121
  class JsonEventsStore {
118
122
  dataDir;
123
+ runtime;
119
124
  channelsPath;
120
125
  eventsPath;
121
126
  deliveriesPath;
122
127
  constructor(dataDir = getEventsDataDir()) {
123
128
  this.dataDir = dataDir;
129
+ this.runtime = localJsonRuntime(dataDir);
124
130
  this.channelsPath = join(dataDir, "channels.json");
125
131
  this.eventsPath = join(dataDir, "events.json");
126
132
  this.deliveriesPath = join(dataDir, "deliveries.json");
@@ -168,13 +174,58 @@ class JsonEventsStore {
168
174
  await this.writeJson(this.eventsPath, events);
169
175
  return event;
170
176
  }
171
- async listEvents() {
177
+ async appendEventOnce(event, options = {}) {
172
178
  await this.init();
173
- return this.readJson(this.eventsPath, []);
179
+ const events = await this.readJson(this.eventsPath, []);
180
+ const dedupe = options.dedupe !== false;
181
+ if (dedupe) {
182
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
183
+ if (existing) {
184
+ return {
185
+ event: existing,
186
+ stored: false,
187
+ deduped: true,
188
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
189
+ };
190
+ }
191
+ }
192
+ events.push(event);
193
+ await this.writeJson(this.eventsPath, events);
194
+ return {
195
+ event,
196
+ stored: true,
197
+ deduped: false,
198
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
199
+ };
200
+ }
201
+ async listEvents(options = {}) {
202
+ await this.init();
203
+ const events = await this.readJson(this.eventsPath, []);
204
+ return queryEvents(events, options);
205
+ }
206
+ async listEventsPage(options = {}) {
207
+ await this.init();
208
+ const events = await this.readJson(this.eventsPath, []);
209
+ const queried = queryEvents(events, {
210
+ eventId: options.eventId,
211
+ source: options.source,
212
+ type: options.type
213
+ });
214
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
215
+ const limit = normalizeEventPageLimit(options.limit);
216
+ const pageEvents = queried.slice(offset, offset + limit);
217
+ const nextOffset = offset + pageEvents.length;
218
+ const hasMore = nextOffset < queried.length;
219
+ return {
220
+ events: pageEvents,
221
+ cursor: options.cursor,
222
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
223
+ hasMore
224
+ };
174
225
  }
175
226
  async findEventByIdentity(identity) {
176
227
  const events = await this.listEvents();
177
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
228
+ return findEventByIdentity(events, identity);
178
229
  }
179
230
  async appendDelivery(result) {
180
231
  await this.init();
@@ -225,6 +276,83 @@ class JsonEventsStore {
225
276
  });
226
277
  }
227
278
  }
279
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
280
+ return {
281
+ mode: "local-files",
282
+ name: "json-events-store",
283
+ remote: false,
284
+ localFiles: true,
285
+ localSqlite: false,
286
+ postgres: false,
287
+ s3: false,
288
+ aws: false,
289
+ durable: true,
290
+ idempotency: "best-effort-local",
291
+ replayCursors: true,
292
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
293
+ };
294
+ }
295
+ function encodeLocalJsonEventCursor(offset, options = {}) {
296
+ if (!Number.isInteger(offset) || offset < 0)
297
+ throw new Error(`Invalid event cursor offset: ${offset}`);
298
+ const payload = {
299
+ offset,
300
+ eventId: options.eventId,
301
+ source: options.source,
302
+ type: options.type
303
+ };
304
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
305
+ }
306
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
307
+ if (!cursor)
308
+ return 0;
309
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
310
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
311
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
312
+ let payload;
313
+ try {
314
+ payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
315
+ } catch {
316
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
317
+ }
318
+ const offset = payload.offset;
319
+ if (!Number.isInteger(offset) || offset < 0)
320
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
321
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
322
+ assertCursorFilter("source", payload.source, options.source);
323
+ assertCursorFilter("type", payload.type, options.type);
324
+ return offset;
325
+ }
326
+ function normalizeEventPageLimit(limit) {
327
+ if (limit === undefined)
328
+ return DEFAULT_EVENT_PAGE_LIMIT;
329
+ if (!Number.isInteger(limit) || limit < 1)
330
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
331
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
332
+ }
333
+ function queryEvents(events, options) {
334
+ let rows = events;
335
+ if (options.eventId)
336
+ rows = rows.filter((event) => event.id === options.eventId);
337
+ if (options.source)
338
+ rows = rows.filter((event) => event.source === options.source);
339
+ if (options.type)
340
+ rows = rows.filter((event) => event.type === options.type);
341
+ if (options.cursor) {
342
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
343
+ rows = rows.slice(offset);
344
+ }
345
+ if (options.limit !== undefined)
346
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
347
+ return rows;
348
+ }
349
+ function assertCursorFilter(name, cursorValue, optionValue) {
350
+ if (cursorValue !== optionValue)
351
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
352
+ }
353
+ function findEventByIdentity(events, identity) {
354
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
355
+ }
228
356
  async function getEventsStatus(dataDir) {
229
357
  const store = new JsonEventsStore(dataDir);
230
358
  await store.init();
@@ -241,6 +369,7 @@ async function getEventsStatus(dataDir) {
241
369
  service: "events",
242
370
  schemaVersion: "1.0",
243
371
  dataDir: store.dataDir,
372
+ storage: store.runtime,
244
373
  env: {
245
374
  primary: HASNA_EVENTS_DIR_ENV,
246
375
  fallback: HASNA_EVENTS_HOME_ENV,
@@ -457,6 +586,182 @@ function createDeliveryResult(event, channel, attempts) {
457
586
  completedAt: attempts.at(-1)?.completedAt ?? now()
458
587
  };
459
588
  }
589
+
590
+ // src/catalog.ts
591
+ class EventValidationError extends Error {
592
+ eventType;
593
+ issues;
594
+ constructor(eventType, issues) {
595
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
596
+ super(`Event validation failed for type "${eventType}": ${detail}`);
597
+ this.name = "EventValidationError";
598
+ this.eventType = eventType;
599
+ this.issues = issues;
600
+ }
601
+ }
602
+
603
+ class EventTypeCatalog {
604
+ definitions = new Map;
605
+ register(definition) {
606
+ this.definitions.set(definition.type, definition);
607
+ return this;
608
+ }
609
+ unregister(type) {
610
+ return this.definitions.delete(type);
611
+ }
612
+ has(type) {
613
+ return this.definitions.has(type);
614
+ }
615
+ get(type) {
616
+ return this.definitions.get(type);
617
+ }
618
+ list() {
619
+ return [...this.definitions.values()];
620
+ }
621
+ validateEvent(event) {
622
+ const definition = this.definitions.get(event.type);
623
+ if (!definition)
624
+ return { ok: true };
625
+ return definition.validate(event.data, event);
626
+ }
627
+ assertEventValid(event) {
628
+ const result = this.validateEvent(event);
629
+ if (!result.ok) {
630
+ throw new EventValidationError(event.type, result.issues);
631
+ }
632
+ }
633
+ }
634
+ var defaultEventTypeCatalog = new EventTypeCatalog;
635
+ var DISTRIBUTION_EVENT_TYPES = {
636
+ releasePublished: "release.published",
637
+ rolloutStarted: "release.rollout.started",
638
+ rolloutCompleted: "release.rollout.completed",
639
+ rolloutFailed: "release.rollout.failed",
640
+ appInstalled: "app.installed",
641
+ announcementSent: "announcement.sent",
642
+ feedbackCreated: "feedback.created",
643
+ feedbackTriaged: "feedback.triaged"
644
+ };
645
+ var DISTRIBUTION_EVENT_CONTRACT_SCHEMAS = {
646
+ "release.published": "hasna.release.v1",
647
+ "release.rollout.started": "hasna.rollout_record.v1",
648
+ "release.rollout.completed": "hasna.rollout_record.v1",
649
+ "release.rollout.failed": "hasna.rollout_record.v1",
650
+ "app.installed": "hasna.rollout_record.v1",
651
+ "announcement.sent": "hasna.announcement.v1",
652
+ "feedback.created": "hasna.feedback.v1",
653
+ "feedback.triaged": "hasna.feedback.v1"
654
+ };
655
+ var PUBLISH_PATHS = ["skill", "ci", "backfilled"];
656
+ var ROLLOUT_ACTIONS = ["install", "update", "rollback", "freeze-blocked"];
657
+ function requireString(data, key, issues) {
658
+ const value = data[key];
659
+ if (typeof value !== "string" || value.trim().length === 0) {
660
+ issues.push({ path: key, message: "must be a non-empty string" });
661
+ }
662
+ }
663
+ function optionalString(data, key, issues) {
664
+ const value = data[key];
665
+ if (value !== undefined && (typeof value !== "string" || value.trim().length === 0)) {
666
+ issues.push({ path: key, message: "must be a non-empty string when present" });
667
+ }
668
+ }
669
+ function optionalEnum(data, key, allowed, issues) {
670
+ const value = data[key];
671
+ if (value !== undefined && (typeof value !== "string" || !allowed.includes(value))) {
672
+ issues.push({ path: key, message: `must be one of: ${allowed.join(", ")}` });
673
+ }
674
+ }
675
+ function optionalStringArray(data, key, issues) {
676
+ const value = data[key];
677
+ if (value === undefined)
678
+ return;
679
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
680
+ issues.push({ path: key, message: "must be an array of non-empty strings when present" });
681
+ }
682
+ }
683
+ function toResult(issues) {
684
+ return issues.length === 0 ? { ok: true } : { ok: false, issues };
685
+ }
686
+ var validateReleasePublishedData = (data) => {
687
+ const issues = [];
688
+ requireString(data, "appId", issues);
689
+ requireString(data, "package", issues);
690
+ requireString(data, "version", issues);
691
+ optionalString(data, "gitSha", issues);
692
+ optionalString(data, "publishedAt", issues);
693
+ optionalEnum(data, "publishPath", PUBLISH_PATHS, issues);
694
+ return toResult(issues);
695
+ };
696
+ var validateRolloutData = (data, event) => {
697
+ const issues = [];
698
+ requireString(data, "appId", issues);
699
+ requireString(data, "package", issues);
700
+ requireString(data, "version", issues);
701
+ requireString(data, "machine", issues);
702
+ optionalEnum(data, "action", ROLLOUT_ACTIONS, issues);
703
+ if (event.type === "release.rollout.completed" || event.type === "release.rollout.failed") {
704
+ requireString(data, "result", issues);
705
+ }
706
+ return toResult(issues);
707
+ };
708
+ var validateAppInstalledData = (data) => {
709
+ const issues = [];
710
+ requireString(data, "appId", issues);
711
+ requireString(data, "package", issues);
712
+ requireString(data, "version", issues);
713
+ requireString(data, "machine", issues);
714
+ return toResult(issues);
715
+ };
716
+ var validateAnnouncementSentData = (data) => {
717
+ const issues = [];
718
+ requireString(data, "campaignId", issues);
719
+ optionalString(data, "appId", issues);
720
+ optionalString(data, "audienceId", issues);
721
+ optionalString(data, "releaseId", issues);
722
+ optionalStringArray(data, "channels", issues);
723
+ return toResult(issues);
724
+ };
725
+ var validateFeedbackCreatedData = (data) => {
726
+ const issues = [];
727
+ requireString(data, "feedbackId", issues);
728
+ optionalString(data, "appId", issues);
729
+ optionalString(data, "source", issues);
730
+ optionalString(data, "summary", issues);
731
+ return toResult(issues);
732
+ };
733
+ var validateFeedbackTriagedData = (data) => {
734
+ const issues = [];
735
+ requireString(data, "feedbackId", issues);
736
+ requireString(data, "disposition", issues);
737
+ optionalString(data, "appId", issues);
738
+ optionalString(data, "triagedBy", issues);
739
+ return toResult(issues);
740
+ };
741
+ function createDistributionEventDefinitions() {
742
+ const bind = (type, validate, description) => ({
743
+ type,
744
+ contractSchemaId: DISTRIBUTION_EVENT_CONTRACT_SCHEMAS[type],
745
+ description,
746
+ validate
747
+ });
748
+ return [
749
+ bind("release.published", validateReleasePublishedData, "A package version was published"),
750
+ bind("release.rollout.started", validateRolloutData, "A rollout of a release to a machine started"),
751
+ bind("release.rollout.completed", validateRolloutData, "A rollout of a release to a machine completed"),
752
+ bind("release.rollout.failed", validateRolloutData, "A rollout of a release to a machine failed"),
753
+ bind("app.installed", validateAppInstalledData, "An app was installed on a machine"),
754
+ bind("announcement.sent", validateAnnouncementSentData, "An announcement campaign was sent"),
755
+ bind("feedback.created", validateFeedbackCreatedData, "User or agent feedback was captured"),
756
+ bind("feedback.triaged", validateFeedbackTriagedData, "Captured feedback was triaged")
757
+ ];
758
+ }
759
+ function registerDistributionEventTypes(catalog = defaultEventTypeCatalog) {
760
+ for (const definition of createDistributionEventDefinitions()) {
761
+ catalog.register(definition);
762
+ }
763
+ return catalog;
764
+ }
460
765
  // src/index.ts
461
766
  import { randomUUID as randomUUID2 } from "crypto";
462
767
  function createEvent(input) {
@@ -479,10 +784,14 @@ class EventsClient {
479
784
  store;
480
785
  redactors;
481
786
  transportOptions;
787
+ catalog;
788
+ validateCatalogTypes;
482
789
  constructor(options = {}) {
483
790
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
484
791
  this.redactors = options.redactors ?? [];
485
792
  this.transportOptions = { fetchImpl: options.fetchImpl };
793
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
794
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
486
795
  }
487
796
  async addChannel(input) {
488
797
  const timestamp = new Date().toISOString();
@@ -500,18 +809,40 @@ class EventsClient {
500
809
  }
501
810
  async emit(input, options = {}) {
502
811
  const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
503
- if (options.dedupe !== false) {
504
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
505
- if (existing) {
506
- return { event: existing, deliveries: [], deduped: true };
507
- }
812
+ if (options.validate ?? this.validateCatalogTypes) {
813
+ this.catalog.assertEventValid(event);
508
814
  }
509
- await this.store.appendEvent(event);
510
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
511
- return { event, deliveries, deduped: false };
815
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
816
+ if (append.deduped) {
817
+ return { event: append.event, deliveries: [], deduped: true };
818
+ }
819
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
820
+ return { event: append.event, deliveries, deduped: false };
512
821
  }
513
- async listEvents() {
514
- return this.store.listEvents();
822
+ async listEvents(options = {}) {
823
+ if (Object.keys(options).length === 0)
824
+ return this.store.listEvents();
825
+ return queryClientEvents(await this.store.listEvents(), options);
826
+ }
827
+ async listEventsPage(options = {}) {
828
+ if (this.store.listEventsPage)
829
+ return this.store.listEventsPage(options);
830
+ const events = queryClientEvents(await this.store.listEvents(), {
831
+ eventId: options.eventId,
832
+ source: options.source,
833
+ type: options.type
834
+ });
835
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
836
+ const limit = normalizeEventPageLimit(options.limit);
837
+ const pageEvents = events.slice(offset, offset + limit);
838
+ const nextOffset = offset + pageEvents.length;
839
+ const hasMore = nextOffset < events.length;
840
+ return {
841
+ events: pageEvents,
842
+ cursor: options.cursor,
843
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
844
+ hasMore
845
+ };
515
846
  }
516
847
  async listDeliveries() {
517
848
  return this.store.listDeliveries();
@@ -579,22 +910,37 @@ class EventsClient {
579
910
  return result;
580
911
  }
581
912
  async replay(options = {}) {
582
- const events = (await this.store.listEvents()).filter((event) => {
583
- if (options.eventId && event.id !== options.eventId)
584
- return false;
585
- if (options.source && event.source !== options.source)
586
- return false;
587
- if (options.type && event.type !== options.type)
588
- return false;
589
- return true;
590
- });
913
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
591
914
  if (options.dryRun)
592
- return { events, deliveries: [] };
915
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
593
916
  const deliveries = [];
594
- for (const event of events) {
917
+ for (const event of page.events) {
595
918
  deliveries.push(...await this.deliver(event));
596
919
  }
597
- return { events, deliveries };
920
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
921
+ }
922
+ async appendEvent(event, options) {
923
+ if (this.store.appendEventOnce) {
924
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
925
+ }
926
+ if (options.dedupe) {
927
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
928
+ if (existing) {
929
+ return {
930
+ event: existing,
931
+ stored: false,
932
+ deduped: true,
933
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
934
+ };
935
+ }
936
+ }
937
+ const stored = await this.store.appendEvent(event);
938
+ return {
939
+ event: stored,
940
+ stored: true,
941
+ deduped: false,
942
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
943
+ };
598
944
  }
599
945
  async applyRedaction(event, channel) {
600
946
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -671,6 +1017,20 @@ function setPath(input, path, replacement) {
671
1017
  if (last && last in cursor)
672
1018
  cursor[last] = replacement;
673
1019
  }
1020
+ function queryClientEvents(events, options) {
1021
+ let rows = events;
1022
+ if (options.eventId)
1023
+ rows = rows.filter((event) => event.id === options.eventId);
1024
+ if (options.source)
1025
+ rows = rows.filter((event) => event.source === options.source);
1026
+ if (options.type)
1027
+ rows = rows.filter((event) => event.type === options.type);
1028
+ if (options.cursor)
1029
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
1030
+ if (options.limit !== undefined)
1031
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
1032
+ return rows;
1033
+ }
674
1034
  function normalizeTime(value) {
675
1035
  if (!value)
676
1036
  return new Date().toISOString();
@@ -757,6 +1117,7 @@ function parseMatcherExpression(value, label) {
757
1117
  }
758
1118
 
759
1119
  // src/commander.ts
1120
+ var DEFAULT_EVENT_LIST_LIMIT = 100;
760
1121
  function parseJsonObject(value, fallback) {
761
1122
  if (!value)
762
1123
  return fallback;
@@ -789,6 +1150,14 @@ function print(value, json, text) {
789
1150
  else
790
1151
  console.log(text);
791
1152
  }
1153
+ function fail(error, json) {
1154
+ const message = error instanceof Error ? error.message : String(error);
1155
+ if (json)
1156
+ console.log(JSON.stringify({ error: message }, null, 2));
1157
+ else
1158
+ console.error(message);
1159
+ process.exitCode = 1;
1160
+ }
792
1161
  function hasJsonOption(options) {
793
1162
  return Boolean(options?.json || options?.opts?.().json || options?.optsWithGlobals?.().json || options?.parent?.opts?.().json || options?.parent?.optsWithGlobals?.().json);
794
1163
  }
@@ -843,26 +1212,36 @@ function registerChannelCommands(program, options) {
843
1212
  print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
844
1213
  });
845
1214
  channels.command("test").description("Send a test event to one channel").argument("<id>", "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) => {
846
- const result = await createClient(options).testChannel(id, {
847
- source: actionOptions.source ?? options.source,
848
- type: actionOptions.type,
849
- subject: actionOptions.subject ?? id,
850
- message: actionOptions.message,
851
- data: parseJsonObject(actionOptions.data, { test: true }),
852
- metadata: parseJsonObject(actionOptions.metadata, {})
853
- }, { honorFilters: actionOptions.honorFilters });
854
- print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
1215
+ const json = wantsJson(actionOptions, command);
1216
+ try {
1217
+ const result = await createClient(options).testChannel(id, {
1218
+ source: actionOptions.source ?? options.source,
1219
+ type: actionOptions.type,
1220
+ subject: actionOptions.subject ?? id,
1221
+ message: actionOptions.message,
1222
+ data: parseJsonObject(actionOptions.data, { test: true }),
1223
+ metadata: parseJsonObject(actionOptions.metadata, {})
1224
+ }, { honorFilters: actionOptions.honorFilters });
1225
+ print(result, json, `${result.status}: ${result.channelId}`);
1226
+ } catch (error) {
1227
+ fail(error, json);
1228
+ }
855
1229
  });
856
1230
  channels.command("match").description("Check whether a sample event matches one channel without delivering").argument("<id>", "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) => {
857
- const result = await createClient(options).matchChannel(id, {
858
- source: actionOptions.source ?? options.source,
859
- type: actionOptions.type,
860
- subject: actionOptions.subject ?? id,
861
- message: actionOptions.message,
862
- data: parseJsonObject(actionOptions.data, { test: true }),
863
- metadata: parseJsonObject(actionOptions.metadata, {})
864
- });
865
- print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
1231
+ const json = wantsJson(actionOptions, command);
1232
+ try {
1233
+ const result = await createClient(options).matchChannel(id, {
1234
+ source: actionOptions.source ?? options.source,
1235
+ type: actionOptions.type,
1236
+ subject: actionOptions.subject ?? id,
1237
+ message: actionOptions.message,
1238
+ data: parseJsonObject(actionOptions.data, { test: true }),
1239
+ metadata: parseJsonObject(actionOptions.metadata, {})
1240
+ });
1241
+ print(result, json, `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
1242
+ } catch (error) {
1243
+ fail(error, json);
1244
+ }
866
1245
  });
867
1246
  return channels;
868
1247
  }
@@ -881,7 +1260,8 @@ function registerEventCommands(program, options) {
881
1260
  }, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
882
1261
  print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
883
1262
  });
884
- events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
1263
+ const defaultListLimit = options.defaultEventListLimit ?? DEFAULT_EVENT_LIST_LIMIT;
1264
+ events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", `Limit to the most recent <n> events (default ${defaultListLimit}; use 0 for all)`, parseNumber, defaultListLimit).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
885
1265
  let rows = await createClient(options).listEvents();
886
1266
  if (actionOptions.source)
887
1267
  rows = rows.filter((event) => event.source === actionOptions.source);
@@ -900,14 +1280,16 @@ function registerEventCommands(program, options) {
900
1280
  for (const event of rows)
901
1281
  console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
902
1282
  });
903
- events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
1283
+ events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--cursor <cursor>", "Opaque replay cursor from a previous page").option("--limit <n>", "Maximum events to replay", parseNumber).option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
904
1284
  const result = await createClient(options).replay({
905
1285
  eventId: actionOptions.id,
906
1286
  source: actionOptions.source,
907
1287
  type: actionOptions.type,
1288
+ cursor: actionOptions.cursor,
1289
+ limit: actionOptions.limit,
908
1290
  dryRun: actionOptions.dryRun
909
1291
  });
910
- print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
1292
+ print(result, wantsJson(actionOptions, command), replaySummary(result.events.length, result.deliveries.length, result.nextCursor));
911
1293
  });
912
1294
  return events;
913
1295
  }
@@ -925,8 +1307,13 @@ function collectValues(value, previous) {
925
1307
  previous.push(value);
926
1308
  return previous;
927
1309
  }
1310
+ function replaySummary(events, deliveries, nextCursor) {
1311
+ const suffix = nextCursor ? `, next cursor: ${nextCursor}` : "";
1312
+ return `Replayed ${events} event(s), ${deliveries} delivery result(s)${suffix}`;
1313
+ }
928
1314
  export {
929
1315
  registerEventsCommands,
930
1316
  registerEventCommands,
931
- registerChannelCommands
1317
+ registerChannelCommands,
1318
+ DEFAULT_EVENT_LIST_LIMIT
932
1319
  };
package/dist/index.d.ts CHANGED
@@ -1,15 +1,29 @@
1
- import type { ChannelConfig, DeliveryResult, EmitOptions, EmitResult, EventEnvelope, EventFilter, EventInput, EventRedactor, ReplayOptions } from "./types.js";
1
+ import type { ChannelConfig, DeliveryResult, EmitOptions, EmitResult, EventEnvelope, EventFilter, EventPage, EventPageOptions, EventInput, EventRedactor, ReplayOptions, ReplayResult } from "./types.js";
2
2
  import { type EventsStore } from "./storage.js";
3
3
  import { type TransportDispatchOptions } from "./transports.js";
4
+ import { type EventTypeCatalog } from "./catalog.js";
4
5
  export * from "./types.js";
5
6
  export * from "./storage.js";
6
7
  export * from "./filter.js";
7
8
  export * from "./signing.js";
8
9
  export * from "./transports.js";
10
+ export * from "./catalog.js";
9
11
  export interface EventsClientOptions extends TransportDispatchOptions {
10
12
  store?: EventsStore;
11
13
  dataDir?: string;
12
14
  redactors?: EventRedactor[];
15
+ /**
16
+ * Event type catalog used by the opt-in emit-time validator hook. Defaults
17
+ * to the shared `defaultEventTypeCatalog`.
18
+ */
19
+ catalog?: EventTypeCatalog;
20
+ /**
21
+ * Opt-in: when true, emitted events whose `type` is registered in the
22
+ * catalog are validated and rejected (with `EventValidationError`) before
23
+ * they are stored or delivered. Unregistered/free-form types always pass.
24
+ * Defaults to false, so existing emitters are untouched.
25
+ */
26
+ validateCatalogTypes?: boolean;
13
27
  }
14
28
  export interface ChannelMatchResult {
15
29
  channelId: string;
@@ -26,20 +40,21 @@ export declare class EventsClient {
26
40
  private store;
27
41
  private redactors;
28
42
  private transportOptions;
43
+ private catalog;
44
+ private validateCatalogTypes;
29
45
  constructor(options?: EventsClientOptions);
30
46
  addChannel(input: Omit<ChannelConfig, "createdAt" | "updatedAt"> & Partial<Pick<ChannelConfig, "createdAt" | "updatedAt">>): Promise<ChannelConfig>;
31
47
  listChannels(): Promise<ChannelConfig[]>;
32
48
  removeChannel(id: string): Promise<boolean>;
33
49
  emit<TData extends Record<string, unknown>>(input: EventInput<TData>, options?: EmitOptions): Promise<EmitResult<TData>>;
34
- listEvents(): Promise<EventEnvelope[]>;
50
+ listEvents(options?: EventPageOptions): Promise<EventEnvelope[]>;
51
+ listEventsPage(options?: EventPageOptions): Promise<EventPage>;
35
52
  listDeliveries(): Promise<DeliveryResult[]>;
36
53
  deliver(event: EventEnvelope): Promise<DeliveryResult[]>;
37
54
  matchChannel(id: string, input?: Partial<EventInput>): Promise<ChannelMatchResult>;
38
55
  testChannel(id: string, input?: Partial<EventInput>, options?: TestChannelOptions): Promise<DeliveryResult>;
39
- replay(options?: ReplayOptions): Promise<{
40
- events: EventEnvelope[];
41
- deliveries: DeliveryResult[];
42
- }>;
56
+ replay(options?: ReplayOptions): Promise<ReplayResult>;
57
+ private appendEvent;
43
58
  private applyRedaction;
44
59
  private deliverWithRetry;
45
60
  }