@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/cli/index.js CHANGED
@@ -108,11 +108,15 @@ function channelMatchesEvent(channel, event) {
108
108
 
109
109
  // src/storage.ts
110
110
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
111
+ import { Buffer as Buffer2 } from "buffer";
111
112
  import { existsSync } from "fs";
112
113
  import { homedir } from "os";
113
114
  import { join } from "path";
114
115
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
115
116
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
117
+ var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
118
+ var DEFAULT_EVENT_PAGE_LIMIT = 100;
119
+ var MAX_EVENT_PAGE_LIMIT = 1000;
116
120
  function getEventsDataDir(override) {
117
121
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
118
122
  }
@@ -126,11 +130,13 @@ function getActiveEventsDirEnv() {
126
130
 
127
131
  class JsonEventsStore {
128
132
  dataDir;
133
+ runtime;
129
134
  channelsPath;
130
135
  eventsPath;
131
136
  deliveriesPath;
132
137
  constructor(dataDir = getEventsDataDir()) {
133
138
  this.dataDir = dataDir;
139
+ this.runtime = localJsonRuntime(dataDir);
134
140
  this.channelsPath = join(dataDir, "channels.json");
135
141
  this.eventsPath = join(dataDir, "events.json");
136
142
  this.deliveriesPath = join(dataDir, "deliveries.json");
@@ -178,13 +184,58 @@ class JsonEventsStore {
178
184
  await this.writeJson(this.eventsPath, events);
179
185
  return event;
180
186
  }
181
- async listEvents() {
187
+ async appendEventOnce(event, options = {}) {
182
188
  await this.init();
183
- return this.readJson(this.eventsPath, []);
189
+ const events = await this.readJson(this.eventsPath, []);
190
+ const dedupe = options.dedupe !== false;
191
+ if (dedupe) {
192
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
193
+ if (existing) {
194
+ return {
195
+ event: existing,
196
+ stored: false,
197
+ deduped: true,
198
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
199
+ };
200
+ }
201
+ }
202
+ events.push(event);
203
+ await this.writeJson(this.eventsPath, events);
204
+ return {
205
+ event,
206
+ stored: true,
207
+ deduped: false,
208
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
209
+ };
210
+ }
211
+ async listEvents(options = {}) {
212
+ await this.init();
213
+ const events = await this.readJson(this.eventsPath, []);
214
+ return queryEvents(events, options);
215
+ }
216
+ async listEventsPage(options = {}) {
217
+ await this.init();
218
+ const events = await this.readJson(this.eventsPath, []);
219
+ const queried = queryEvents(events, {
220
+ eventId: options.eventId,
221
+ source: options.source,
222
+ type: options.type
223
+ });
224
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
225
+ const limit = normalizeEventPageLimit(options.limit);
226
+ const pageEvents = queried.slice(offset, offset + limit);
227
+ const nextOffset = offset + pageEvents.length;
228
+ const hasMore = nextOffset < queried.length;
229
+ return {
230
+ events: pageEvents,
231
+ cursor: options.cursor,
232
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
233
+ hasMore
234
+ };
184
235
  }
185
236
  async findEventByIdentity(identity) {
186
237
  const events = await this.listEvents();
187
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
238
+ return findEventByIdentity(events, identity);
188
239
  }
189
240
  async appendDelivery(result) {
190
241
  await this.init();
@@ -235,6 +286,83 @@ class JsonEventsStore {
235
286
  });
236
287
  }
237
288
  }
289
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
290
+ return {
291
+ mode: "local-files",
292
+ name: "json-events-store",
293
+ remote: false,
294
+ localFiles: true,
295
+ localSqlite: false,
296
+ postgres: false,
297
+ s3: false,
298
+ aws: false,
299
+ durable: true,
300
+ idempotency: "best-effort-local",
301
+ replayCursors: true,
302
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
303
+ };
304
+ }
305
+ function encodeLocalJsonEventCursor(offset, options = {}) {
306
+ if (!Number.isInteger(offset) || offset < 0)
307
+ throw new Error(`Invalid event cursor offset: ${offset}`);
308
+ const payload = {
309
+ offset,
310
+ eventId: options.eventId,
311
+ source: options.source,
312
+ type: options.type
313
+ };
314
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
315
+ }
316
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
317
+ if (!cursor)
318
+ return 0;
319
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
320
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
321
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
322
+ let payload;
323
+ try {
324
+ payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
325
+ } catch {
326
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
327
+ }
328
+ const offset = payload.offset;
329
+ if (!Number.isInteger(offset) || offset < 0)
330
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
331
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
332
+ assertCursorFilter("source", payload.source, options.source);
333
+ assertCursorFilter("type", payload.type, options.type);
334
+ return offset;
335
+ }
336
+ function normalizeEventPageLimit(limit) {
337
+ if (limit === undefined)
338
+ return DEFAULT_EVENT_PAGE_LIMIT;
339
+ if (!Number.isInteger(limit) || limit < 1)
340
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
341
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
342
+ }
343
+ function queryEvents(events, options) {
344
+ let rows = events;
345
+ if (options.eventId)
346
+ rows = rows.filter((event) => event.id === options.eventId);
347
+ if (options.source)
348
+ rows = rows.filter((event) => event.source === options.source);
349
+ if (options.type)
350
+ rows = rows.filter((event) => event.type === options.type);
351
+ if (options.cursor) {
352
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
353
+ rows = rows.slice(offset);
354
+ }
355
+ if (options.limit !== undefined)
356
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
357
+ return rows;
358
+ }
359
+ function assertCursorFilter(name, cursorValue, optionValue) {
360
+ if (cursorValue !== optionValue)
361
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
362
+ }
363
+ function findEventByIdentity(events, identity) {
364
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
365
+ }
238
366
  async function getEventsStatus(dataDir) {
239
367
  const store = new JsonEventsStore(dataDir);
240
368
  await store.init();
@@ -251,6 +379,7 @@ async function getEventsStatus(dataDir) {
251
379
  service: "events",
252
380
  schemaVersion: "1.0",
253
381
  dataDir: store.dataDir,
382
+ storage: store.runtime,
254
383
  env: {
255
384
  primary: HASNA_EVENTS_DIR_ENV,
256
385
  fallback: HASNA_EVENTS_HOME_ENV,
@@ -447,6 +576,52 @@ function createDeliveryResult(event, channel, attempts) {
447
576
  };
448
577
  }
449
578
 
579
+ // src/catalog.ts
580
+ class EventValidationError extends Error {
581
+ eventType;
582
+ issues;
583
+ constructor(eventType, issues) {
584
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
585
+ super(`Event validation failed for type "${eventType}": ${detail}`);
586
+ this.name = "EventValidationError";
587
+ this.eventType = eventType;
588
+ this.issues = issues;
589
+ }
590
+ }
591
+
592
+ class EventTypeCatalog {
593
+ definitions = new Map;
594
+ register(definition) {
595
+ this.definitions.set(definition.type, definition);
596
+ return this;
597
+ }
598
+ unregister(type) {
599
+ return this.definitions.delete(type);
600
+ }
601
+ has(type) {
602
+ return this.definitions.has(type);
603
+ }
604
+ get(type) {
605
+ return this.definitions.get(type);
606
+ }
607
+ list() {
608
+ return [...this.definitions.values()];
609
+ }
610
+ validateEvent(event) {
611
+ const definition = this.definitions.get(event.type);
612
+ if (!definition)
613
+ return { ok: true };
614
+ return definition.validate(event.data, event);
615
+ }
616
+ assertEventValid(event) {
617
+ const result = this.validateEvent(event);
618
+ if (!result.ok) {
619
+ throw new EventValidationError(event.type, result.issues);
620
+ }
621
+ }
622
+ }
623
+ var defaultEventTypeCatalog = new EventTypeCatalog;
624
+
450
625
  // src/index.ts
451
626
  function createEvent(input) {
452
627
  return {
@@ -468,10 +643,14 @@ class EventsClient {
468
643
  store;
469
644
  redactors;
470
645
  transportOptions;
646
+ catalog;
647
+ validateCatalogTypes;
471
648
  constructor(options = {}) {
472
649
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
473
650
  this.redactors = options.redactors ?? [];
474
651
  this.transportOptions = { fetchImpl: options.fetchImpl };
652
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
653
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
475
654
  }
476
655
  async addChannel(input) {
477
656
  const timestamp = new Date().toISOString();
@@ -489,18 +668,40 @@ class EventsClient {
489
668
  }
490
669
  async emit(input, options = {}) {
491
670
  const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
492
- if (options.dedupe !== false) {
493
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
494
- if (existing) {
495
- return { event: existing, deliveries: [], deduped: true };
496
- }
671
+ if (options.validate ?? this.validateCatalogTypes) {
672
+ this.catalog.assertEventValid(event);
673
+ }
674
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
675
+ if (append.deduped) {
676
+ return { event: append.event, deliveries: [], deduped: true };
497
677
  }
498
- await this.store.appendEvent(event);
499
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
500
- return { event, deliveries, deduped: false };
678
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
679
+ return { event: append.event, deliveries, deduped: false };
501
680
  }
502
- async listEvents() {
503
- return this.store.listEvents();
681
+ async listEvents(options = {}) {
682
+ if (Object.keys(options).length === 0)
683
+ return this.store.listEvents();
684
+ return queryClientEvents(await this.store.listEvents(), options);
685
+ }
686
+ async listEventsPage(options = {}) {
687
+ if (this.store.listEventsPage)
688
+ return this.store.listEventsPage(options);
689
+ const events = queryClientEvents(await this.store.listEvents(), {
690
+ eventId: options.eventId,
691
+ source: options.source,
692
+ type: options.type
693
+ });
694
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
695
+ const limit = normalizeEventPageLimit(options.limit);
696
+ const pageEvents = events.slice(offset, offset + limit);
697
+ const nextOffset = offset + pageEvents.length;
698
+ const hasMore = nextOffset < events.length;
699
+ return {
700
+ events: pageEvents,
701
+ cursor: options.cursor,
702
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
703
+ hasMore
704
+ };
504
705
  }
505
706
  async listDeliveries() {
506
707
  return this.store.listDeliveries();
@@ -568,22 +769,37 @@ class EventsClient {
568
769
  return result;
569
770
  }
570
771
  async replay(options = {}) {
571
- const events = (await this.store.listEvents()).filter((event) => {
572
- if (options.eventId && event.id !== options.eventId)
573
- return false;
574
- if (options.source && event.source !== options.source)
575
- return false;
576
- if (options.type && event.type !== options.type)
577
- return false;
578
- return true;
579
- });
772
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
580
773
  if (options.dryRun)
581
- return { events, deliveries: [] };
774
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
582
775
  const deliveries = [];
583
- for (const event of events) {
776
+ for (const event of page.events) {
584
777
  deliveries.push(...await this.deliver(event));
585
778
  }
586
- return { events, deliveries };
779
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
780
+ }
781
+ async appendEvent(event, options) {
782
+ if (this.store.appendEventOnce) {
783
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
784
+ }
785
+ if (options.dedupe) {
786
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
787
+ if (existing) {
788
+ return {
789
+ event: existing,
790
+ stored: false,
791
+ deduped: true,
792
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
793
+ };
794
+ }
795
+ }
796
+ const stored = await this.store.appendEvent(event);
797
+ return {
798
+ event: stored,
799
+ stored: true,
800
+ deduped: false,
801
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
802
+ };
587
803
  }
588
804
  async applyRedaction(event, channel) {
589
805
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -660,6 +876,20 @@ function setPath(input, path, replacement) {
660
876
  if (last && last in cursor)
661
877
  cursor[last] = replacement;
662
878
  }
879
+ function queryClientEvents(events, options) {
880
+ let rows = events;
881
+ if (options.eventId)
882
+ rows = rows.filter((event) => event.id === options.eventId);
883
+ if (options.source)
884
+ rows = rows.filter((event) => event.source === options.source);
885
+ if (options.type)
886
+ rows = rows.filter((event) => event.type === options.type);
887
+ if (options.cursor)
888
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
889
+ if (options.limit !== undefined)
890
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
891
+ return rows;
892
+ }
663
893
  function normalizeTime(value) {
664
894
  if (!value)
665
895
  return new Date().toISOString();
@@ -871,7 +1101,7 @@ Usage:
871
1101
  ${name} [--dir <path>] [--json] status
872
1102
  ${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
873
1103
  ${name} [--dir <path>] [--json] events list [--limit <n>]
874
- ${name} [--dir <path>] [--json] events replay [--id <event-id>] [--dry-run]
1104
+ ${name} [--dir <path>] [--json] events replay [--id <event-id>] [--cursor <cursor>] [--limit <n>] [--dry-run]
875
1105
 
876
1106
  Environment:
877
1107
  HASNA_EVENTS_DIR or HASNA_EVENTS_HOME overrides the default ${getEventsDataDir()}`);
@@ -947,7 +1177,7 @@ function printEventsHelp(options = {}) {
947
1177
  Usage:
948
1178
  ${name} [--dir <path>] [--json] events emit <type>${options.source ? "" : " --source <source>"} [options]
949
1179
  ${name} [--dir <path>] [--json] events list [--limit <n>]
950
- ${name} [--dir <path>] [--json] events replay [--id <event-id>] [--dry-run]
1180
+ ${name} [--dir <path>] [--json] events replay [--id <event-id>] [--cursor <cursor>] [--limit <n>] [--dry-run]
951
1181
 
952
1182
  Options:
953
1183
  --source <source> Event source${options.source ? ` (default: ${options.source})` : ""}
@@ -958,6 +1188,8 @@ Options:
958
1188
  --data <json> JSON object payload
959
1189
  --metadata <json> JSON object metadata
960
1190
  --no-deliver Record without delivering channels
1191
+ --cursor <cursor> Opaque cursor returned by a previous replay page
1192
+ --limit <n> Maximum events to replay
961
1193
  --dry-run Preview replay matches without delivery`);
962
1194
  }
963
1195
  async function runEventsCli(argv = process.argv.slice(2), options = {}) {
@@ -1178,9 +1410,11 @@ async function handleEvents(client, command, tail, parsed, options) {
1178
1410
  eventId: takeOption(args, "--id"),
1179
1411
  source: takeOption(args, "--source"),
1180
1412
  type: takeOption(args, "--type"),
1413
+ cursor: takeOption(args, "--cursor"),
1414
+ limit: numberOption(takeOption(args, "--limit")),
1181
1415
  dryRun: takeFlag(args, "--dry-run")
1182
1416
  });
1183
- output(parsed, result, () => console.log(`Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`));
1417
+ output(parsed, result, () => console.log(replaySummary(result.events.length, result.deliveries.length, result.nextCursor)));
1184
1418
  return;
1185
1419
  }
1186
1420
  throw new Error(`Unknown events command: ${command ?? ""}`);
@@ -1201,6 +1435,10 @@ function severityOption(value) {
1201
1435
  throw new Error(`Invalid severity: ${value}`);
1202
1436
  return value;
1203
1437
  }
1438
+ function replaySummary(events, deliveries, nextCursor) {
1439
+ const suffix = nextCursor ? `, next cursor: ${nextCursor}` : "";
1440
+ return `Replayed ${events} event(s), ${deliveries} delivery result(s)${suffix}`;
1441
+ }
1204
1442
  if (import.meta.main) {
1205
1443
  runEventsCli().catch((error) => {
1206
1444
  const parsed = parseGlobalArgs(process.argv.slice(2));
@@ -7,7 +7,21 @@ export interface RegisterEventsCommandsOptions {
7
7
  createClient?: () => EventsClient;
8
8
  channelsCommandName?: string;
9
9
  eventsCommandName?: string;
10
+ /**
11
+ * Default row cap applied to `events list` when the caller does not pass an
12
+ * explicit `--limit`. Guards against dumping the entire event store (a
13
+ * usability/performance hazard for hosts with large stores). Pass `--limit 0`
14
+ * to opt out and list every recorded event. Defaults to
15
+ * {@link DEFAULT_EVENT_LIST_LIMIT}.
16
+ */
17
+ defaultEventListLimit?: number;
10
18
  }
19
+ /**
20
+ * Sane default number of most-recent events returned by `events list` when the
21
+ * host does not configure {@link RegisterEventsCommandsOptions.defaultEventListLimit}
22
+ * and the user does not pass an explicit `--limit`.
23
+ */
24
+ export declare const DEFAULT_EVENT_LIST_LIMIT = 100;
11
25
  export declare function registerChannelCommands(program: CommanderLike, options: RegisterEventsCommandsOptions): CommanderCommandLike;
12
26
  export declare function registerEventCommands(program: CommanderLike, options: RegisterEventsCommandsOptions): CommanderCommandLike;
13
27
  export declare function registerEventsCommands(program: CommanderLike, options: RegisterEventsCommandsOptions): void;