@hasna/events 0.1.13 → 0.1.15

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,
@@ -315,21 +444,27 @@ function now() {
315
444
  function truncate(value, max = 4096) {
316
445
  return value.length > max ? `${value.slice(0, max)}...` : value;
317
446
  }
318
- function buildWebhookRequest(event, channel) {
447
+ function buildWebhookRequest(event, channel, options = {}) {
319
448
  if (!channel.webhook)
320
449
  throw new Error(`Channel ${channel.id} has no webhook config`);
450
+ for (const name of Object.keys(channel.webhook.headers ?? {})) {
451
+ if (/^x-hasna-/i.test(name)) {
452
+ throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
453
+ }
454
+ }
321
455
  const body = JSON.stringify(event);
322
- const timestamp = event.time;
456
+ const timestamp = options.timestamp ?? new Date().toISOString();
323
457
  const headers = {
324
458
  "Content-Type": "application/json",
325
459
  "User-Agent": "@hasna/events",
326
460
  "X-Hasna-Event-Id": event.id,
327
461
  "X-Hasna-Event-Type": event.type,
328
- "X-Hasna-Timestamp": timestamp,
329
- ...channel.webhook.headers
462
+ ...channel.webhook.headers,
463
+ "X-Hasna-Timestamp": timestamp
330
464
  };
331
- if (channel.webhook.secret) {
332
- headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
465
+ const secret = options.secret ?? channel.webhook.secret;
466
+ if (secret) {
467
+ headers["X-Hasna-Signature"] = signPayload(secret, timestamp, body);
333
468
  }
334
469
  return { body, headers };
335
470
  }
@@ -337,7 +472,21 @@ async function dispatchWebhook(event, channel, options = {}) {
337
472
  if (!channel.webhook)
338
473
  throw new Error(`Channel ${channel.id} has no webhook config`);
339
474
  const startedAt = now();
340
- const { body, headers } = buildWebhookRequest(event, channel);
475
+ let secret = channel.webhook.secret;
476
+ if (channel.webhook.secretRef) {
477
+ if (!options.secretResolver) {
478
+ return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
479
+ }
480
+ try {
481
+ secret = await options.secretResolver(channel.webhook.secretRef);
482
+ } catch {
483
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
484
+ }
485
+ if (!secret)
486
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
487
+ }
488
+ const timestamp = (options.now?.() ?? new Date).toISOString();
489
+ const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
341
490
  const controller = new AbortController;
342
491
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
343
492
  try {
@@ -369,6 +518,15 @@ async function dispatchWebhook(event, channel, options = {}) {
369
518
  clearTimeout(timeout);
370
519
  }
371
520
  }
521
+ function failedAttempt(startedAt, error) {
522
+ return {
523
+ attempt: 1,
524
+ status: "failed",
525
+ startedAt,
526
+ completedAt: now(),
527
+ error
528
+ };
529
+ }
372
530
  async function dispatchCommand(event, channel) {
373
531
  if (!channel.command)
374
532
  throw new Error(`Channel ${channel.id} has no command config`);
@@ -457,8 +615,592 @@ function createDeliveryResult(event, channel, attempts) {
457
615
  completedAt: attempts.at(-1)?.completedAt ?? now()
458
616
  };
459
617
  }
618
+
619
+ // src/catalog.ts
620
+ class EventValidationError extends Error {
621
+ eventType;
622
+ issues;
623
+ constructor(eventType, issues) {
624
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
625
+ super(`Event validation failed for type "${eventType}": ${detail}`);
626
+ this.name = "EventValidationError";
627
+ this.eventType = eventType;
628
+ this.issues = issues;
629
+ }
630
+ }
631
+
632
+ class EventTypeCatalog {
633
+ definitions = new Map;
634
+ register(definition) {
635
+ this.definitions.set(definition.type, definition);
636
+ return this;
637
+ }
638
+ unregister(type) {
639
+ return this.definitions.delete(type);
640
+ }
641
+ has(type) {
642
+ return this.definitions.has(type);
643
+ }
644
+ get(type) {
645
+ return this.definitions.get(type);
646
+ }
647
+ list() {
648
+ return [...this.definitions.values()];
649
+ }
650
+ validateEvent(event) {
651
+ const definition = this.definitions.get(event.type);
652
+ if (!definition)
653
+ return { ok: true };
654
+ return definition.validate(event.data, event);
655
+ }
656
+ assertEventValid(event) {
657
+ const result = this.validateEvent(event);
658
+ if (!result.ok) {
659
+ throw new EventValidationError(event.type, result.issues);
660
+ }
661
+ }
662
+ }
663
+ var defaultEventTypeCatalog = new EventTypeCatalog;
664
+ var DISTRIBUTION_EVENT_TYPES = {
665
+ releasePublished: "release.published",
666
+ rolloutStarted: "release.rollout.started",
667
+ rolloutCompleted: "release.rollout.completed",
668
+ rolloutFailed: "release.rollout.failed",
669
+ appInstalled: "app.installed",
670
+ announcementSent: "announcement.sent",
671
+ feedbackCreated: "feedback.created",
672
+ feedbackTriaged: "feedback.triaged"
673
+ };
674
+ var DISTRIBUTION_EVENT_CONTRACT_SCHEMAS = {
675
+ "release.published": "hasna.release.v1",
676
+ "release.rollout.started": "hasna.rollout_record.v1",
677
+ "release.rollout.completed": "hasna.rollout_record.v1",
678
+ "release.rollout.failed": "hasna.rollout_record.v1",
679
+ "app.installed": "hasna.rollout_record.v1",
680
+ "announcement.sent": "hasna.announcement.v1",
681
+ "feedback.created": "hasna.feedback.v1",
682
+ "feedback.triaged": "hasna.feedback.v1"
683
+ };
684
+ var PUBLISH_PATHS = ["skill", "ci", "backfilled"];
685
+ var ROLLOUT_ACTIONS = ["install", "update", "rollback", "freeze-blocked"];
686
+ function requireString(data, key, issues) {
687
+ const value = data[key];
688
+ if (typeof value !== "string" || value.trim().length === 0) {
689
+ issues.push({ path: key, message: "must be a non-empty string" });
690
+ }
691
+ }
692
+ function optionalString(data, key, issues) {
693
+ const value = data[key];
694
+ if (value !== undefined && (typeof value !== "string" || value.trim().length === 0)) {
695
+ issues.push({ path: key, message: "must be a non-empty string when present" });
696
+ }
697
+ }
698
+ function optionalEnum(data, key, allowed, issues) {
699
+ const value = data[key];
700
+ if (value !== undefined && (typeof value !== "string" || !allowed.includes(value))) {
701
+ issues.push({ path: key, message: `must be one of: ${allowed.join(", ")}` });
702
+ }
703
+ }
704
+ function optionalStringArray(data, key, issues) {
705
+ const value = data[key];
706
+ if (value === undefined)
707
+ return;
708
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
709
+ issues.push({ path: key, message: "must be an array of non-empty strings when present" });
710
+ }
711
+ }
712
+ function toResult(issues) {
713
+ return issues.length === 0 ? { ok: true } : { ok: false, issues };
714
+ }
715
+ var validateReleasePublishedData = (data) => {
716
+ const issues = [];
717
+ requireString(data, "appId", issues);
718
+ requireString(data, "package", issues);
719
+ requireString(data, "version", issues);
720
+ optionalString(data, "gitSha", issues);
721
+ optionalString(data, "publishedAt", issues);
722
+ optionalEnum(data, "publishPath", PUBLISH_PATHS, issues);
723
+ return toResult(issues);
724
+ };
725
+ var validateRolloutData = (data, event) => {
726
+ const issues = [];
727
+ requireString(data, "appId", issues);
728
+ requireString(data, "package", issues);
729
+ requireString(data, "version", issues);
730
+ requireString(data, "machine", issues);
731
+ optionalEnum(data, "action", ROLLOUT_ACTIONS, issues);
732
+ if (event.type === "release.rollout.completed" || event.type === "release.rollout.failed") {
733
+ requireString(data, "result", issues);
734
+ }
735
+ return toResult(issues);
736
+ };
737
+ var validateAppInstalledData = (data) => {
738
+ const issues = [];
739
+ requireString(data, "appId", issues);
740
+ requireString(data, "package", issues);
741
+ requireString(data, "version", issues);
742
+ requireString(data, "machine", issues);
743
+ return toResult(issues);
744
+ };
745
+ var validateAnnouncementSentData = (data) => {
746
+ const issues = [];
747
+ requireString(data, "campaignId", issues);
748
+ optionalString(data, "appId", issues);
749
+ optionalString(data, "audienceId", issues);
750
+ optionalString(data, "releaseId", issues);
751
+ optionalStringArray(data, "channels", issues);
752
+ return toResult(issues);
753
+ };
754
+ var validateFeedbackCreatedData = (data) => {
755
+ const issues = [];
756
+ requireString(data, "feedbackId", issues);
757
+ optionalString(data, "appId", issues);
758
+ optionalString(data, "source", issues);
759
+ optionalString(data, "summary", issues);
760
+ return toResult(issues);
761
+ };
762
+ var validateFeedbackTriagedData = (data) => {
763
+ const issues = [];
764
+ requireString(data, "feedbackId", issues);
765
+ requireString(data, "disposition", issues);
766
+ optionalString(data, "appId", issues);
767
+ optionalString(data, "triagedBy", issues);
768
+ return toResult(issues);
769
+ };
770
+ function createDistributionEventDefinitions() {
771
+ const bind = (type, validate, description) => ({
772
+ type,
773
+ contractSchemaId: DISTRIBUTION_EVENT_CONTRACT_SCHEMAS[type],
774
+ description,
775
+ validate
776
+ });
777
+ return [
778
+ bind("release.published", validateReleasePublishedData, "A package version was published"),
779
+ bind("release.rollout.started", validateRolloutData, "A rollout of a release to a machine started"),
780
+ bind("release.rollout.completed", validateRolloutData, "A rollout of a release to a machine completed"),
781
+ bind("release.rollout.failed", validateRolloutData, "A rollout of a release to a machine failed"),
782
+ bind("app.installed", validateAppInstalledData, "An app was installed on a machine"),
783
+ bind("announcement.sent", validateAnnouncementSentData, "An announcement campaign was sent"),
784
+ bind("feedback.created", validateFeedbackCreatedData, "User or agent feedback was captured"),
785
+ bind("feedback.triaged", validateFeedbackTriagedData, "Captured feedback was triaged")
786
+ ];
787
+ }
788
+ function registerDistributionEventTypes(catalog = defaultEventTypeCatalog) {
789
+ for (const definition of createDistributionEventDefinitions()) {
790
+ catalog.register(definition);
791
+ }
792
+ return catalog;
793
+ }
794
+ // src/app-event.ts
795
+ var APP_EVENT_V1_SCHEMA_VERSION = "hasna.app_event.v1";
796
+ var APP_EVENT_V1_METADATA_KEY = "app_event";
797
+ var APP_EVENT_V1_MAX_SUMMARY_LENGTH = 512;
798
+ var APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
799
+ var APP_EVENT_V1_MAX_REFS = 32;
800
+ var APP_EVENT_V1_MAX_TARGETS = 16;
801
+
802
+ class AppEventValidationError extends Error {
803
+ issues;
804
+ constructor(issues) {
805
+ super(`Invalid ${APP_EVENT_V1_SCHEMA_VERSION}: ${issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
806
+ this.name = "AppEventValidationError";
807
+ this.issues = issues;
808
+ }
809
+ }
810
+
811
+ class AppEventReplaySafetyError extends Error {
812
+ eventId;
813
+ constructor(eventId) {
814
+ super(`App event ${eventId} is not marked replay-safe`);
815
+ this.name = "AppEventReplaySafetyError";
816
+ this.eventId = eventId;
817
+ }
818
+ }
819
+ var SEVERITIES = ["debug", "info", "notice", "warning", "error", "critical"];
820
+ var ACTOR_KINDS = ["agent", "human", "service", "model", "workflow", "system"];
821
+ var SENSITIVITIES = ["public", "internal", "confidential", "restricted"];
822
+ var REDACTION_STATES = ["none", "partial", "full"];
823
+ var DELIVERY_INTENTS = ["notification", "state_sync", "audit", "command"];
824
+ var DELIVERY_MODES = ["at_most_once", "at_least_once"];
825
+ function validateAppEventV1(value) {
826
+ const issues = [];
827
+ if (!isRecord(value))
828
+ return { ok: false, issues: [{ path: "<root>", message: "must be an object" }] };
829
+ rejectUnknownKeys(value, [
830
+ "event_id",
831
+ "event_type",
832
+ "schema_version",
833
+ "source",
834
+ "occurred_at",
835
+ "severity",
836
+ "idempotency",
837
+ "correlation",
838
+ "subject",
839
+ "actor",
840
+ "project_mappings",
841
+ "summary",
842
+ "data",
843
+ "resource_refs",
844
+ "evidence_refs",
845
+ "sensitivity",
846
+ "redaction",
847
+ "delivery"
848
+ ], "", issues);
849
+ requireString2(value, "event_id", "event_id", issues, 200);
850
+ requireString2(value, "event_type", "event_type", issues, 200);
851
+ if (value.schema_version !== APP_EVENT_V1_SCHEMA_VERSION) {
852
+ issues.push({ path: "schema_version", message: `must equal ${APP_EVENT_V1_SCHEMA_VERSION}` });
853
+ }
854
+ requireTimestamp(value, "occurred_at", issues);
855
+ requireEnum(value, "severity", SEVERITIES, "severity", issues);
856
+ requireString2(value, "summary", "summary", issues, APP_EVENT_V1_MAX_SUMMARY_LENGTH);
857
+ const source = requireRecord(value, "source", issues);
858
+ if (source) {
859
+ rejectUnknownKeys(source, ["app", "version", "machine"], "source", issues);
860
+ requireString2(source, "app", "source.app", issues, 200);
861
+ requireString2(source, "version", "source.version", issues, 100);
862
+ requireString2(source, "machine", "source.machine", issues, 200);
863
+ }
864
+ const idempotency = requireRecord(value, "idempotency", issues);
865
+ if (idempotency) {
866
+ rejectUnknownKeys(idempotency, ["dedupe_key", "replay_safe", "replay_of_event_id"], "idempotency", issues);
867
+ requireString2(idempotency, "dedupe_key", "idempotency.dedupe_key", issues, 512);
868
+ requireBoolean(idempotency, "replay_safe", "idempotency.replay_safe", issues);
869
+ optionalString2(idempotency, "replay_of_event_id", "idempotency.replay_of_event_id", issues, 200);
870
+ if (idempotency.replay_of_event_id === value.event_id) {
871
+ issues.push({ path: "idempotency.replay_of_event_id", message: "must not reference the event itself" });
872
+ }
873
+ }
874
+ const correlation = requireRecord(value, "correlation", issues);
875
+ if (correlation) {
876
+ rejectUnknownKeys(correlation, ["correlation_id", "causation_id", "trace_id"], "correlation", issues);
877
+ requireString2(correlation, "correlation_id", "correlation.correlation_id", issues, 200);
878
+ optionalString2(correlation, "causation_id", "correlation.causation_id", issues, 200);
879
+ optionalString2(correlation, "trace_id", "correlation.trace_id", issues, 200);
880
+ }
881
+ validateSubject(value, issues);
882
+ validateActor(value, issues);
883
+ validateProjectMappings(value, issues);
884
+ validateData(value.data, issues);
885
+ validateResourceRefs(value.resource_refs, issues);
886
+ validateEvidenceRefs(value.evidence_refs, issues);
887
+ validateSensitivity(value, issues);
888
+ validateRedaction(value, issues);
889
+ validateDelivery(value, issues);
890
+ return issues.length === 0 ? { ok: true } : { ok: false, issues };
891
+ }
892
+ function assertAppEventV1(value) {
893
+ const result = validateAppEventV1(value);
894
+ if (!result.ok)
895
+ throw new AppEventValidationError(result.issues);
896
+ }
897
+ function assertAppEventV1ReplaySafe(event) {
898
+ assertAppEventV1(event);
899
+ if (!event.idempotency.replay_safe)
900
+ throw new AppEventReplaySafetyError(event.event_id);
901
+ }
902
+ function appEventV1ReplayIdentity(event) {
903
+ assertAppEventV1ReplaySafe(event);
904
+ return { eventId: event.event_id, dedupeKey: event.idempotency.dedupe_key };
905
+ }
906
+ function appEventV1ToEventInput(event) {
907
+ assertAppEventV1(event);
908
+ const metadata = {
909
+ profile: APP_EVENT_V1_SCHEMA_VERSION,
910
+ source_version: event.source.version,
911
+ source_machine: event.source.machine,
912
+ replay_safe: event.idempotency.replay_safe,
913
+ replay_of_event_id: event.idempotency.replay_of_event_id,
914
+ correlation: structuredClone(event.correlation),
915
+ subject: structuredClone(event.subject),
916
+ actor: structuredClone(event.actor),
917
+ project_mappings: structuredClone(event.project_mappings),
918
+ resource_refs: structuredClone(event.resource_refs),
919
+ evidence_refs: structuredClone(event.evidence_refs),
920
+ sensitivity: structuredClone(event.sensitivity),
921
+ redaction: structuredClone(event.redaction),
922
+ delivery: structuredClone(event.delivery)
923
+ };
924
+ return {
925
+ id: event.event_id,
926
+ source: event.source.app,
927
+ type: event.event_type,
928
+ time: event.occurred_at,
929
+ subject: event.subject.uri ?? `${event.subject.kind}:${event.subject.id}`,
930
+ severity: event.severity,
931
+ data: structuredClone(event.data),
932
+ message: event.summary,
933
+ dedupeKey: event.idempotency.dedupe_key,
934
+ schemaVersion: APP_EVENT_V1_SCHEMA_VERSION,
935
+ metadata: { [APP_EVENT_V1_METADATA_KEY]: metadata }
936
+ };
937
+ }
938
+ function appEventV1FromEventEnvelope(envelope) {
939
+ const metadata = envelope.metadata[APP_EVENT_V1_METADATA_KEY];
940
+ if (!isRecord(metadata) || metadata.profile !== APP_EVENT_V1_SCHEMA_VERSION) {
941
+ throw new AppEventValidationError([{
942
+ path: `metadata.${APP_EVENT_V1_METADATA_KEY}.profile`,
943
+ message: `must equal ${APP_EVENT_V1_SCHEMA_VERSION}`
944
+ }]);
945
+ }
946
+ const event = {
947
+ event_id: envelope.id,
948
+ event_type: envelope.type,
949
+ schema_version: envelope.schemaVersion,
950
+ source: {
951
+ app: envelope.source,
952
+ version: metadata.source_version,
953
+ machine: metadata.source_machine
954
+ },
955
+ occurred_at: envelope.time,
956
+ severity: envelope.severity,
957
+ idempotency: {
958
+ dedupe_key: envelope.dedupeKey,
959
+ replay_safe: metadata.replay_safe,
960
+ replay_of_event_id: metadata.replay_of_event_id
961
+ },
962
+ correlation: metadata.correlation,
963
+ subject: metadata.subject,
964
+ actor: metadata.actor,
965
+ project_mappings: metadata.project_mappings,
966
+ summary: envelope.message,
967
+ data: structuredClone(envelope.data),
968
+ resource_refs: metadata.resource_refs,
969
+ evidence_refs: metadata.evidence_refs,
970
+ sensitivity: metadata.sensitivity,
971
+ redaction: metadata.redaction,
972
+ delivery: metadata.delivery
973
+ };
974
+ assertAppEventV1(event);
975
+ return structuredClone(event);
976
+ }
977
+ function validateSubject(value, issues) {
978
+ const subject = requireRecord(value, "subject", issues);
979
+ if (!subject)
980
+ return;
981
+ rejectUnknownKeys(subject, ["kind", "id", "uri"], "subject", issues);
982
+ requireString2(subject, "kind", "subject.kind", issues, 100);
983
+ requireString2(subject, "id", "subject.id", issues, 200);
984
+ optionalString2(subject, "uri", "subject.uri", issues, 2048);
985
+ }
986
+ function validateActor(value, issues) {
987
+ const actor = requireRecord(value, "actor", issues);
988
+ if (!actor)
989
+ return;
990
+ rejectUnknownKeys(actor, ["kind", "id", "name"], "actor", issues);
991
+ requireEnum(actor, "kind", ACTOR_KINDS, "actor.kind", issues);
992
+ requireString2(actor, "id", "actor.id", issues, 200);
993
+ optionalString2(actor, "name", "actor.name", issues, 200);
994
+ }
995
+ function validateProjectMappings(value, issues) {
996
+ const project = requireRecord(value, "project_mappings", issues);
997
+ if (!project)
998
+ return;
999
+ rejectUnknownKeys(project, ["canonical_id", "slug", "repository", "workspace", "external_ids"], "project_mappings", issues);
1000
+ requireString2(project, "canonical_id", "project_mappings.canonical_id", issues, 200);
1001
+ optionalString2(project, "slug", "project_mappings.slug", issues, 200);
1002
+ optionalString2(project, "repository", "project_mappings.repository", issues, 2048);
1003
+ optionalString2(project, "workspace", "project_mappings.workspace", issues, 2048);
1004
+ const externalIds = requireRecord(project, "external_ids", issues, "project_mappings.external_ids");
1005
+ if (externalIds) {
1006
+ if (Object.keys(externalIds).length > APP_EVENT_V1_MAX_TARGETS) {
1007
+ issues.push({ path: "project_mappings.external_ids", message: `must have at most ${APP_EVENT_V1_MAX_TARGETS} entries` });
1008
+ }
1009
+ for (const [key, entry] of Object.entries(externalIds)) {
1010
+ if (!key.trim() || typeof entry !== "string" || !entry.trim()) {
1011
+ issues.push({ path: `project_mappings.external_ids.${key}`, message: "keys and values must be non-empty strings" });
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+ function validateData(value, issues) {
1017
+ if (!isRecord(value)) {
1018
+ issues.push({ path: "data", message: "must be an object" });
1019
+ return;
1020
+ }
1021
+ try {
1022
+ const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
1023
+ if (bytes > APP_EVENT_V1_MAX_DATA_BYTES) {
1024
+ issues.push({ path: "data", message: `must serialize to at most ${APP_EVENT_V1_MAX_DATA_BYTES} UTF-8 bytes` });
1025
+ }
1026
+ } catch {
1027
+ issues.push({ path: "data", message: "must be JSON serializable" });
1028
+ }
1029
+ }
1030
+ function validateResourceRefs(value, issues) {
1031
+ validateRefArray(value, "resource_refs", issues, (ref, path) => {
1032
+ rejectUnknownKeys(ref, ["kind", "id", "uri", "source_package", "external_id"], path, issues);
1033
+ requireString2(ref, "kind", `${path}.kind`, issues, 100);
1034
+ requireString2(ref, "id", `${path}.id`, issues, 200);
1035
+ optionalString2(ref, "uri", `${path}.uri`, issues, 2048);
1036
+ optionalString2(ref, "source_package", `${path}.source_package`, issues, 200);
1037
+ optionalString2(ref, "external_id", `${path}.external_id`, issues, 200);
1038
+ });
1039
+ }
1040
+ function validateEvidenceRefs(value, issues) {
1041
+ validateRefArray(value, "evidence_refs", issues, (ref, path) => {
1042
+ rejectUnknownKeys(ref, ["kind", "id", "uri", "sha256", "redaction"], path, issues);
1043
+ requireString2(ref, "kind", `${path}.kind`, issues, 100);
1044
+ requireString2(ref, "id", `${path}.id`, issues, 200);
1045
+ requireString2(ref, "uri", `${path}.uri`, issues, 2048);
1046
+ optionalString2(ref, "sha256", `${path}.sha256`, issues, 64);
1047
+ if (typeof ref.sha256 === "string" && !/^[a-f0-9]{64}$/i.test(ref.sha256)) {
1048
+ issues.push({ path: `${path}.sha256`, message: "must be a 64-character hexadecimal digest" });
1049
+ }
1050
+ requireEnum(ref, "redaction", REDACTION_STATES, `${path}.redaction`, issues);
1051
+ });
1052
+ }
1053
+ function validateSensitivity(value, issues) {
1054
+ const sensitivity = requireRecord(value, "sensitivity", issues);
1055
+ if (!sensitivity)
1056
+ return;
1057
+ rejectUnknownKeys(sensitivity, ["classification", "contains_personal_data"], "sensitivity", issues);
1058
+ requireEnum(sensitivity, "classification", SENSITIVITIES, "sensitivity.classification", issues);
1059
+ requireBoolean(sensitivity, "contains_personal_data", "sensitivity.contains_personal_data", issues);
1060
+ }
1061
+ function validateRedaction(value, issues) {
1062
+ const redaction = requireRecord(value, "redaction", issues);
1063
+ if (!redaction)
1064
+ return;
1065
+ rejectUnknownKeys(redaction, ["state", "fields", "safe_for_logs"], "redaction", issues);
1066
+ requireEnum(redaction, "state", REDACTION_STATES, "redaction.state", issues);
1067
+ validateStringArray(redaction.fields, "redaction.fields", APP_EVENT_V1_MAX_REFS, issues, true);
1068
+ requireBoolean(redaction, "safe_for_logs", "redaction.safe_for_logs", issues);
1069
+ if (redaction.state === "none" && Array.isArray(redaction.fields) && redaction.fields.length > 0) {
1070
+ issues.push({ path: "redaction.fields", message: "must be empty when redaction.state is none" });
1071
+ }
1072
+ }
1073
+ function validateDelivery(value, issues) {
1074
+ const delivery = requireRecord(value, "delivery", issues);
1075
+ if (!delivery)
1076
+ return;
1077
+ rejectUnknownKeys(delivery, ["intent", "mode", "targets", "agent_conversation_injection"], "delivery", issues);
1078
+ requireEnum(delivery, "intent", DELIVERY_INTENTS, "delivery.intent", issues);
1079
+ requireEnum(delivery, "mode", DELIVERY_MODES, "delivery.mode", issues);
1080
+ validateStringArray(delivery.targets, "delivery.targets", APP_EVENT_V1_MAX_TARGETS, issues, false);
1081
+ if (delivery.agent_conversation_injection !== false) {
1082
+ issues.push({ path: "delivery.agent_conversation_injection", message: "must be false" });
1083
+ }
1084
+ }
1085
+ function validateRefArray(value, path, issues, validate) {
1086
+ if (!Array.isArray(value)) {
1087
+ issues.push({ path, message: "must be an array" });
1088
+ return;
1089
+ }
1090
+ if (value.length > APP_EVENT_V1_MAX_REFS) {
1091
+ issues.push({ path, message: `must contain at most ${APP_EVENT_V1_MAX_REFS} entries` });
1092
+ }
1093
+ value.forEach((entry, index) => {
1094
+ if (!isRecord(entry))
1095
+ issues.push({ path: `${path}.${index}`, message: "must be an object" });
1096
+ else
1097
+ validate(entry, `${path}.${index}`);
1098
+ });
1099
+ }
1100
+ function validateStringArray(value, path, maxItems, issues, allowEmpty) {
1101
+ if (!Array.isArray(value)) {
1102
+ issues.push({ path, message: "must be an array" });
1103
+ return;
1104
+ }
1105
+ if (!allowEmpty && value.length === 0)
1106
+ issues.push({ path, message: "must contain at least one entry" });
1107
+ if (value.length > maxItems)
1108
+ issues.push({ path, message: `must contain at most ${maxItems} entries` });
1109
+ value.forEach((entry, index) => {
1110
+ if (typeof entry !== "string" || !entry.trim()) {
1111
+ issues.push({ path: `${path}.${index}`, message: "must be a non-empty string" });
1112
+ }
1113
+ });
1114
+ }
1115
+ function isRecord(value) {
1116
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1117
+ }
1118
+ function rejectUnknownKeys(value, allowed, path, issues) {
1119
+ for (const key of Object.keys(value)) {
1120
+ if (!allowed.includes(key))
1121
+ issues.push({ path: path ? `${path}.${key}` : key, message: "is not allowed" });
1122
+ }
1123
+ }
1124
+ function requireRecord(value, key, issues, path = key) {
1125
+ const entry = value[key];
1126
+ if (!isRecord(entry)) {
1127
+ issues.push({ path, message: "must be an object" });
1128
+ return;
1129
+ }
1130
+ return entry;
1131
+ }
1132
+ function requireString2(value, key, path, issues, maxLength) {
1133
+ const entry = value[key];
1134
+ if (typeof entry !== "string" || !entry.trim())
1135
+ issues.push({ path, message: "must be a non-empty string" });
1136
+ else if (entry.length > maxLength)
1137
+ issues.push({ path, message: `must have at most ${maxLength} characters` });
1138
+ }
1139
+ function optionalString2(value, key, path, issues, maxLength) {
1140
+ if (value[key] === undefined)
1141
+ return;
1142
+ requireString2(value, key, path, issues, maxLength);
1143
+ }
1144
+ function requireBoolean(value, key, path, issues) {
1145
+ if (typeof value[key] !== "boolean")
1146
+ issues.push({ path, message: "must be a boolean" });
1147
+ }
1148
+ function requireEnum(value, key, allowed, path, issues) {
1149
+ if (typeof value[key] !== "string" || !allowed.includes(value[key])) {
1150
+ issues.push({ path, message: `must be one of: ${allowed.join(", ")}` });
1151
+ }
1152
+ }
1153
+ function requireTimestamp(value, key, issues) {
1154
+ const entry = value[key];
1155
+ if (typeof entry !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(entry) || Number.isNaN(Date.parse(entry))) {
1156
+ issues.push({ path: key, message: "must be an RFC 3339 date-time" });
1157
+ }
1158
+ }
1159
+
460
1160
  // src/index.ts
461
1161
  import { randomUUID as randomUUID2 } from "crypto";
1162
+
1163
+ // src/redaction.ts
1164
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
1165
+ if (paths.length === 0)
1166
+ return event;
1167
+ const copy = structuredClone(event);
1168
+ for (const path of paths) {
1169
+ setPath(copy, path, replacement);
1170
+ }
1171
+ return copy;
1172
+ }
1173
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
1174
+ return redactValue(event, replacement);
1175
+ }
1176
+ function shouldRedactKey(key) {
1177
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
1178
+ }
1179
+ function redactValue(value, replacement) {
1180
+ if (Array.isArray(value))
1181
+ return value.map((item) => redactValue(item, replacement));
1182
+ if (!value || typeof value !== "object")
1183
+ return value;
1184
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
1185
+ key,
1186
+ shouldRedactKey(key) ? replacement : redactValue(item, replacement)
1187
+ ]));
1188
+ }
1189
+ function setPath(input, path, replacement) {
1190
+ const parts = path.split(".");
1191
+ let cursor = input;
1192
+ for (const part of parts.slice(0, -1)) {
1193
+ const next = cursor[part];
1194
+ if (!next || typeof next !== "object")
1195
+ return;
1196
+ cursor = next;
1197
+ }
1198
+ const last = parts.at(-1);
1199
+ if (last && last in cursor)
1200
+ cursor[last] = replacement;
1201
+ }
1202
+
1203
+ // src/index.ts
462
1204
  function createEvent(input) {
463
1205
  return {
464
1206
  id: input.id ?? randomUUID2(),
@@ -479,10 +1221,18 @@ class EventsClient {
479
1221
  store;
480
1222
  redactors;
481
1223
  transportOptions;
1224
+ catalog;
1225
+ validateCatalogTypes;
482
1226
  constructor(options = {}) {
483
1227
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
484
1228
  this.redactors = options.redactors ?? [];
485
- this.transportOptions = { fetchImpl: options.fetchImpl };
1229
+ this.transportOptions = {
1230
+ fetchImpl: options.fetchImpl,
1231
+ secretResolver: options.secretResolver,
1232
+ now: options.now
1233
+ };
1234
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
1235
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
486
1236
  }
487
1237
  async addChannel(input) {
488
1238
  const timestamp = new Date().toISOString();
@@ -500,18 +1250,40 @@ class EventsClient {
500
1250
  }
501
1251
  async emit(input, options = {}) {
502
1252
  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
- }
1253
+ if (options.validate ?? this.validateCatalogTypes) {
1254
+ this.catalog.assertEventValid(event);
508
1255
  }
509
- await this.store.appendEvent(event);
510
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
511
- return { event, deliveries, deduped: false };
1256
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
1257
+ if (append.deduped) {
1258
+ return { event: append.event, deliveries: [], deduped: true };
1259
+ }
1260
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
1261
+ return { event: append.event, deliveries, deduped: false };
512
1262
  }
513
- async listEvents() {
514
- return this.store.listEvents();
1263
+ async listEvents(options = {}) {
1264
+ if (Object.keys(options).length === 0)
1265
+ return this.store.listEvents();
1266
+ return queryClientEvents(await this.store.listEvents(), options);
1267
+ }
1268
+ async listEventsPage(options = {}) {
1269
+ if (this.store.listEventsPage)
1270
+ return this.store.listEventsPage(options);
1271
+ const events = queryClientEvents(await this.store.listEvents(), {
1272
+ eventId: options.eventId,
1273
+ source: options.source,
1274
+ type: options.type
1275
+ });
1276
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
1277
+ const limit = normalizeEventPageLimit(options.limit);
1278
+ const pageEvents = events.slice(offset, offset + limit);
1279
+ const nextOffset = offset + pageEvents.length;
1280
+ const hasMore = nextOffset < events.length;
1281
+ return {
1282
+ events: pageEvents,
1283
+ cursor: options.cursor,
1284
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
1285
+ hasMore
1286
+ };
515
1287
  }
516
1288
  async listDeliveries() {
517
1289
  return this.store.listDeliveries();
@@ -579,22 +1351,37 @@ class EventsClient {
579
1351
  return result;
580
1352
  }
581
1353
  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
- });
1354
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
591
1355
  if (options.dryRun)
592
- return { events, deliveries: [] };
1356
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
593
1357
  const deliveries = [];
594
- for (const event of events) {
1358
+ for (const event of page.events) {
595
1359
  deliveries.push(...await this.deliver(event));
596
1360
  }
597
- return { events, deliveries };
1361
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
1362
+ }
1363
+ async appendEvent(event, options) {
1364
+ if (this.store.appendEventOnce) {
1365
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
1366
+ }
1367
+ if (options.dedupe) {
1368
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
1369
+ if (existing) {
1370
+ return {
1371
+ event: existing,
1372
+ stored: false,
1373
+ deduped: true,
1374
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
1375
+ };
1376
+ }
1377
+ }
1378
+ const stored = await this.store.appendEvent(event);
1379
+ return {
1380
+ event: stored,
1381
+ stored: true,
1382
+ deduped: false,
1383
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
1384
+ };
598
1385
  }
599
1386
  async applyRedaction(event, channel) {
600
1387
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -621,15 +1408,6 @@ class EventsClient {
621
1408
  return createDeliveryResult(event, channel, attempts);
622
1409
  }
623
1410
  }
624
- function redactPaths(event, paths, replacement = "[REDACTED]") {
625
- if (paths.length === 0)
626
- return event;
627
- const copy = structuredClone(event);
628
- for (const path of paths) {
629
- setPath(copy, path, replacement);
630
- }
631
- return copy;
632
- }
633
1411
  function sanitizeChannelForOutput(channel) {
634
1412
  const copy = structuredClone(channel);
635
1413
  if (copy.webhook?.secret)
@@ -642,34 +1420,19 @@ function sanitizeChannelForOutput(channel) {
642
1420
  function sanitizeChannelsForOutput(channels) {
643
1421
  return channels.map(sanitizeChannelForOutput);
644
1422
  }
645
- function redactSensitiveKeys(event, replacement = "[REDACTED]") {
646
- return redactValue(event, replacement);
647
- }
648
- function shouldRedactKey(key) {
649
- return /secret|token|password|api[_-]?key|authorization/i.test(key);
650
- }
651
- function redactValue(value, replacement) {
652
- if (Array.isArray(value))
653
- return value.map((item) => redactValue(item, replacement));
654
- if (!value || typeof value !== "object")
655
- return value;
656
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
657
- key,
658
- shouldRedactKey(key) ? replacement : redactValue(item, replacement)
659
- ]));
660
- }
661
- function setPath(input, path, replacement) {
662
- const parts = path.split(".");
663
- let cursor = input;
664
- for (const part of parts.slice(0, -1)) {
665
- const next = cursor[part];
666
- if (!next || typeof next !== "object")
667
- return;
668
- cursor = next;
669
- }
670
- const last = parts.at(-1);
671
- if (last && last in cursor)
672
- cursor[last] = replacement;
1423
+ function queryClientEvents(events, options) {
1424
+ let rows = events;
1425
+ if (options.eventId)
1426
+ rows = rows.filter((event) => event.id === options.eventId);
1427
+ if (options.source)
1428
+ rows = rows.filter((event) => event.source === options.source);
1429
+ if (options.type)
1430
+ rows = rows.filter((event) => event.type === options.type);
1431
+ if (options.cursor)
1432
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
1433
+ if (options.limit !== undefined)
1434
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
1435
+ return rows;
673
1436
  }
674
1437
  function normalizeTime(value) {
675
1438
  if (!value)
@@ -757,6 +1520,7 @@ function parseMatcherExpression(value, label) {
757
1520
  }
758
1521
 
759
1522
  // src/commander.ts
1523
+ var DEFAULT_EVENT_LIST_LIMIT = 100;
760
1524
  function parseJsonObject(value, fallback) {
761
1525
  if (!value)
762
1526
  return fallback;
@@ -789,6 +1553,14 @@ function print(value, json, text) {
789
1553
  else
790
1554
  console.log(text);
791
1555
  }
1556
+ function fail(error, json) {
1557
+ const message = error instanceof Error ? error.message : String(error);
1558
+ if (json)
1559
+ console.log(JSON.stringify({ error: message }, null, 2));
1560
+ else
1561
+ console.error(message);
1562
+ process.exitCode = 1;
1563
+ }
792
1564
  function hasJsonOption(options) {
793
1565
  return Boolean(options?.json || options?.opts?.().json || options?.optsWithGlobals?.().json || options?.parent?.opts?.().json || options?.parent?.optsWithGlobals?.().json);
794
1566
  }
@@ -843,26 +1615,36 @@ function registerChannelCommands(program, options) {
843
1615
  print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
844
1616
  });
845
1617
  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}`);
1618
+ const json = wantsJson(actionOptions, command);
1619
+ try {
1620
+ const result = await createClient(options).testChannel(id, {
1621
+ source: actionOptions.source ?? options.source,
1622
+ type: actionOptions.type,
1623
+ subject: actionOptions.subject ?? id,
1624
+ message: actionOptions.message,
1625
+ data: parseJsonObject(actionOptions.data, { test: true }),
1626
+ metadata: parseJsonObject(actionOptions.metadata, {})
1627
+ }, { honorFilters: actionOptions.honorFilters });
1628
+ print(result, json, `${result.status}: ${result.channelId}`);
1629
+ } catch (error) {
1630
+ fail(error, json);
1631
+ }
855
1632
  });
856
1633
  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}`);
1634
+ const json = wantsJson(actionOptions, command);
1635
+ try {
1636
+ const result = await createClient(options).matchChannel(id, {
1637
+ source: actionOptions.source ?? options.source,
1638
+ type: actionOptions.type,
1639
+ subject: actionOptions.subject ?? id,
1640
+ message: actionOptions.message,
1641
+ data: parseJsonObject(actionOptions.data, { test: true }),
1642
+ metadata: parseJsonObject(actionOptions.metadata, {})
1643
+ });
1644
+ print(result, json, `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
1645
+ } catch (error) {
1646
+ fail(error, json);
1647
+ }
866
1648
  });
867
1649
  return channels;
868
1650
  }
@@ -881,7 +1663,8 @@ function registerEventCommands(program, options) {
881
1663
  }, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
882
1664
  print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
883
1665
  });
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) => {
1666
+ const defaultListLimit = options.defaultEventListLimit ?? DEFAULT_EVENT_LIST_LIMIT;
1667
+ 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
1668
  let rows = await createClient(options).listEvents();
886
1669
  if (actionOptions.source)
887
1670
  rows = rows.filter((event) => event.source === actionOptions.source);
@@ -900,14 +1683,16 @@ function registerEventCommands(program, options) {
900
1683
  for (const event of rows)
901
1684
  console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
902
1685
  });
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) => {
1686
+ 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
1687
  const result = await createClient(options).replay({
905
1688
  eventId: actionOptions.id,
906
1689
  source: actionOptions.source,
907
1690
  type: actionOptions.type,
1691
+ cursor: actionOptions.cursor,
1692
+ limit: actionOptions.limit,
908
1693
  dryRun: actionOptions.dryRun
909
1694
  });
910
- print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
1695
+ print(result, wantsJson(actionOptions, command), replaySummary(result.events.length, result.deliveries.length, result.nextCursor));
911
1696
  });
912
1697
  return events;
913
1698
  }
@@ -925,8 +1710,13 @@ function collectValues(value, previous) {
925
1710
  previous.push(value);
926
1711
  return previous;
927
1712
  }
1713
+ function replaySummary(events, deliveries, nextCursor) {
1714
+ const suffix = nextCursor ? `, next cursor: ${nextCursor}` : "";
1715
+ return `Replayed ${events} event(s), ${deliveries} delivery result(s)${suffix}`;
1716
+ }
928
1717
  export {
929
1718
  registerEventsCommands,
930
1719
  registerEventCommands,
931
- registerChannelCommands
1720
+ registerChannelCommands,
1721
+ DEFAULT_EVENT_LIST_LIMIT
932
1722
  };