@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/README.md +95 -3
- package/dist/catalog.d.ts +136 -0
- package/dist/catalog.js +191 -0
- package/dist/cli/index.js +266 -28
- package/dist/commander.d.ts +14 -0
- package/dist/commander.js +434 -47
- package/dist/index.d.ts +21 -6
- package/dist/index.js +406 -26
- package/dist/storage.d.ts +16 -3
- package/dist/storage.js +140 -4
- package/dist/types.d.ts +55 -2
- package/package.json +6 -2
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
|
|
177
|
+
async appendEventOnce(event, options = {}) {
|
|
172
178
|
await this.init();
|
|
173
|
-
|
|
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
|
|
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.
|
|
504
|
-
|
|
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.
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
41
|
-
deliveries: DeliveryResult[];
|
|
42
|
-
}>;
|
|
56
|
+
replay(options?: ReplayOptions): Promise<ReplayResult>;
|
|
57
|
+
private appendEvent;
|
|
43
58
|
private applyRedaction;
|
|
44
59
|
private deliverWithRetry;
|
|
45
60
|
}
|