@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/index.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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
812
|
+
if (options.validate ?? this.validateCatalogTypes) {
|
|
813
|
+
this.catalog.assertEventValid(event);
|
|
814
|
+
}
|
|
815
|
+
const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
|
|
816
|
+
if (append.deduped) {
|
|
817
|
+
return { event: append.event, deliveries: [], deduped: true };
|
|
508
818
|
}
|
|
509
|
-
await this.
|
|
510
|
-
|
|
511
|
-
return { event, deliveries, deduped: false };
|
|
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();
|
|
@@ -686,28 +1046,48 @@ function normalizeRetryPolicy(policy) {
|
|
|
686
1046
|
export {
|
|
687
1047
|
verifyWebhookSignature,
|
|
688
1048
|
verifyPayloadSignature,
|
|
1049
|
+
validateRolloutData,
|
|
1050
|
+
validateReleasePublishedData,
|
|
1051
|
+
validateFeedbackTriagedData,
|
|
1052
|
+
validateFeedbackCreatedData,
|
|
1053
|
+
validateAppInstalledData,
|
|
1054
|
+
validateAnnouncementSentData,
|
|
689
1055
|
signPayload,
|
|
690
1056
|
sanitizeChannelsForOutput,
|
|
691
1057
|
sanitizeChannelForOutput,
|
|
1058
|
+
registerDistributionEventTypes,
|
|
692
1059
|
redactSensitiveKeys,
|
|
693
1060
|
redactPaths,
|
|
1061
|
+
normalizeEventPageLimit,
|
|
694
1062
|
matchString,
|
|
1063
|
+
localJsonRuntime,
|
|
695
1064
|
isTimestampWithinTolerance,
|
|
696
1065
|
getEventsStatus,
|
|
697
1066
|
getEventsDataDir,
|
|
698
1067
|
getActiveEventsDirEnv,
|
|
699
1068
|
eventMatchesFilter,
|
|
1069
|
+
encodeLocalJsonEventCursor,
|
|
700
1070
|
dispatchWebhook,
|
|
701
1071
|
dispatchCommand,
|
|
702
1072
|
dispatchChannel,
|
|
1073
|
+
defaultEventTypeCatalog,
|
|
1074
|
+
decodeLocalJsonEventCursor,
|
|
703
1075
|
createEvent,
|
|
1076
|
+
createDistributionEventDefinitions,
|
|
704
1077
|
createDeliveryResult,
|
|
705
1078
|
channelMatchesEvent,
|
|
706
1079
|
buildWebhookRequest,
|
|
707
1080
|
buildSignatureBase,
|
|
1081
|
+
MAX_EVENT_PAGE_LIMIT,
|
|
1082
|
+
LOCAL_JSON_EVENT_CURSOR_PREFIX,
|
|
708
1083
|
JsonEventsStore,
|
|
709
1084
|
HASNA_EVENTS_HOME_ENV,
|
|
710
1085
|
HASNA_EVENTS_DIR_ENV,
|
|
711
1086
|
EventsClient,
|
|
712
|
-
|
|
1087
|
+
EventValidationError,
|
|
1088
|
+
EventTypeCatalog,
|
|
1089
|
+
DISTRIBUTION_EVENT_TYPES,
|
|
1090
|
+
DISTRIBUTION_EVENT_CONTRACT_SCHEMAS,
|
|
1091
|
+
DEFAULT_SIGNATURE_TOLERANCE_MS,
|
|
1092
|
+
DEFAULT_EVENT_PAGE_LIMIT
|
|
713
1093
|
};
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import type { ChannelConfig, DeliveryResult, EventEnvelope, EventsStatus, StoredEventsData } from "./types.js";
|
|
1
|
+
import type { ChannelConfig, DeliveryResult, EventAppendOptions, EventAppendResult, EventEnvelope, EventPage, EventPageOptions, EventsStatus, EventsStoreRuntime, StoredEventsData } from "./types.js";
|
|
2
2
|
export declare const HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
3
3
|
export declare const HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
4
|
+
export declare const LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
|
|
5
|
+
export declare const DEFAULT_EVENT_PAGE_LIMIT = 100;
|
|
6
|
+
export declare const MAX_EVENT_PAGE_LIMIT = 1000;
|
|
4
7
|
export declare function getEventsDataDir(override?: string): string;
|
|
5
8
|
export declare function getActiveEventsDirEnv(): EventsStatus["env"]["active"];
|
|
6
9
|
export interface EventsStore {
|
|
7
10
|
dataDir: string;
|
|
11
|
+
runtime?: EventsStoreRuntime;
|
|
8
12
|
init(): Promise<void>;
|
|
9
13
|
addChannel(channel: ChannelConfig): Promise<ChannelConfig>;
|
|
10
14
|
listChannels(): Promise<ChannelConfig[]>;
|
|
11
15
|
getChannel(id: string): Promise<ChannelConfig | undefined>;
|
|
12
16
|
removeChannel(id: string): Promise<boolean>;
|
|
13
17
|
appendEvent(event: EventEnvelope): Promise<EventEnvelope>;
|
|
14
|
-
|
|
18
|
+
appendEventOnce?(event: EventEnvelope, options?: EventAppendOptions): Promise<EventAppendResult>;
|
|
19
|
+
listEvents(options?: EventPageOptions): Promise<EventEnvelope[]>;
|
|
20
|
+
listEventsPage?(options?: EventPageOptions): Promise<EventPage>;
|
|
15
21
|
findEventByIdentity(identity: {
|
|
16
22
|
id?: string;
|
|
17
23
|
dedupeKey?: string;
|
|
@@ -21,6 +27,7 @@ export interface EventsStore {
|
|
|
21
27
|
}
|
|
22
28
|
export declare class JsonEventsStore implements EventsStore {
|
|
23
29
|
dataDir: string;
|
|
30
|
+
runtime: EventsStoreRuntime;
|
|
24
31
|
private channelsPath;
|
|
25
32
|
private eventsPath;
|
|
26
33
|
private deliveriesPath;
|
|
@@ -31,7 +38,9 @@ export declare class JsonEventsStore implements EventsStore {
|
|
|
31
38
|
getChannel(id: string): Promise<ChannelConfig | undefined>;
|
|
32
39
|
removeChannel(id: string): Promise<boolean>;
|
|
33
40
|
appendEvent(event: EventEnvelope): Promise<EventEnvelope>;
|
|
34
|
-
|
|
41
|
+
appendEventOnce(event: EventEnvelope, options?: EventAppendOptions): Promise<EventAppendResult>;
|
|
42
|
+
listEvents(options?: EventPageOptions): Promise<EventEnvelope[]>;
|
|
43
|
+
listEventsPage(options?: EventPageOptions): Promise<EventPage>;
|
|
35
44
|
findEventByIdentity(identity: {
|
|
36
45
|
id?: string;
|
|
37
46
|
dedupeKey?: string;
|
|
@@ -43,4 +52,8 @@ export declare class JsonEventsStore implements EventsStore {
|
|
|
43
52
|
private readJson;
|
|
44
53
|
private writeJson;
|
|
45
54
|
}
|
|
55
|
+
export declare function localJsonRuntime(dataDir?: string): EventsStoreRuntime;
|
|
56
|
+
export declare function encodeLocalJsonEventCursor(offset: number, options?: EventPageOptions): string;
|
|
57
|
+
export declare function decodeLocalJsonEventCursor(cursor: string | undefined, options?: EventPageOptions): number;
|
|
58
|
+
export declare function normalizeEventPageLimit(limit: number | undefined): number;
|
|
46
59
|
export declare function getEventsStatus(dataDir?: string): Promise<EventsStatus>;
|