@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 CHANGED
@@ -10,6 +10,45 @@ This package is local-first. By default it stores JSON files under `~/.hasna/eve
10
10
 
11
11
  Override the data directory with `HASNA_EVENTS_DIR`, `HASNA_EVENTS_HOME`, or the CLI `--dir` flag.
12
12
 
13
+ ## Storage Runtime Contract
14
+
15
+ The default runtime is local JSON files. It does not use local SQLite, remote
16
+ Postgres, S3, AWS infrastructure, or live cloud mutation:
17
+
18
+ ```ts
19
+ import { getEventsStatus } from "@hasna/events";
20
+
21
+ const status = await getEventsStatus();
22
+
23
+ console.log(status.storage);
24
+ // {
25
+ // mode: "local-files",
26
+ // localFiles: true,
27
+ // localSqlite: false,
28
+ // remote: false,
29
+ // postgres: false,
30
+ // s3: false,
31
+ // aws: false,
32
+ // idempotency: "best-effort-local",
33
+ // replayCursors: true
34
+ // }
35
+ ```
36
+
37
+ Cloud-backed stores should implement the same `EventsStore` interface and, for
38
+ durable event-bus use, the optional `appendEventOnce` and `listEventsPage`
39
+ methods. `appendEventOnce` is the storage-layer hook for atomic idempotency by
40
+ `id` or `dedupeKey`, such as a Postgres unique constraint or equivalent
41
+ provider guarantee. `listEventsPage` returns an opaque cursor page for bounded
42
+ replay. The local JSON store implements these hooks for deterministic local
43
+ tests, but its idempotency is best-effort local file behavior, not a
44
+ cross-process database lock.
45
+
46
+ Remote Postgres/S3/AWS adapters should keep credentials and infrastructure
47
+ provisioning outside this package configuration, report their storage mode in
48
+ `status.storage`, and avoid emitting live external deliveries during store
49
+ tests. Creating buckets, databases, secrets, migrations, or production data
50
+ changes is an explicit deployment/approval step, not part of this local runtime.
51
+
13
52
  ## Install
14
53
 
15
54
  ```bash
@@ -58,6 +97,51 @@ Envelope fields are:
58
97
 
59
98
  `source` should be the emitting app or bounded context. `type` should use dot notation such as `ticket.created`, `repo.synced`, or `check.failed`.
60
99
 
100
+ ## Typed Event Catalog (Distribution Events)
101
+
102
+ `@hasna/events/catalog` binds well-known envelope `type` strings to the
103
+ `@hasna/contracts` schema ids their `data` payloads mirror, and provides an
104
+ OPT-IN emit-time validator hook.
105
+
106
+ Distribution event types (`DISTRIBUTION_EVENT_TYPES`):
107
+
108
+ | Event type | Contracts schema (`data` mirror) |
109
+ | --- | --- |
110
+ | `release.published` | `hasna.release.v1` |
111
+ | `release.rollout.started` | `hasna.rollout_record.v1` |
112
+ | `release.rollout.completed` | `hasna.rollout_record.v1` |
113
+ | `release.rollout.failed` | `hasna.rollout_record.v1` |
114
+ | `app.installed` | `hasna.rollout_record.v1` |
115
+ | `announcement.sent` | `hasna.announcement.v1` |
116
+ | `feedback.created` | `hasna.feedback.v1` |
117
+ | `feedback.triaged` | `hasna.feedback.v1` |
118
+
119
+ ```ts
120
+ import { EventsClient, EventTypeCatalog, registerDistributionEventTypes } from "@hasna/events";
121
+
122
+ const catalog = registerDistributionEventTypes(new EventTypeCatalog());
123
+ const client = new EventsClient({ catalog, validateCatalogTypes: true });
124
+
125
+ // Registered type with an invalid payload throws EventValidationError
126
+ // BEFORE the event is stored or delivered.
127
+ await client.emit({
128
+ source: "open-publish",
129
+ type: "release.published",
130
+ data: { appId: "open-todos" },
131
+ });
132
+ ```
133
+
134
+ Validation is fully backward compatible:
135
+
136
+ - It is OFF by default (`validateCatalogTypes` defaults to `false`).
137
+ - Unregistered/free-form event types ALWAYS pass, even when validation is on.
138
+ - A per-emit `validate` option overrides the client setting in both directions.
139
+
140
+ Payload types (`ReleasePublishedData`, `RolloutData`, `AppInstalledData`,
141
+ `AnnouncementSentData`, `FeedbackCreatedData`, `FeedbackTriagedData`) are
142
+ dependency-free structural mirrors of the contracts schemas; this package does
143
+ not depend on `@hasna/contracts` at runtime.
144
+
61
145
  ## OpenAutomations Trigger Ingress
62
146
 
63
147
  `@hasna/events` is trigger ingress for OpenAutomations. It records and delivers
@@ -306,18 +390,26 @@ events events emit ticket.created \
306
390
 
307
391
  events events list --limit 20
308
392
  events events replay --type ticket.created
393
+ events events replay --type ticket.created --dry-run --limit 100
394
+ events events replay --type ticket.created --cursor "$NEXT_CURSOR" --limit 100
309
395
  events events replay --dry-run
310
396
  ```
311
397
 
398
+ Replay cursors are opaque and tied to the same filter set (`--id`, `--source`,
399
+ and `--type`) used to produce them. Use the `nextCursor` returned by the
400
+ previous JSON replay response rather than constructing cursor strings in
401
+ callers. A replay without `--limit` or `--cursor` processes all matching events;
402
+ use those flags when callers need bounded page-by-page replay.
403
+
312
404
  Machine-readable status:
313
405
 
314
406
  ```bash
315
407
  events status --json
316
408
  ```
317
409
 
318
- The status contract reports event, channel, delivery, file, and transport counts
319
- only. It does not include event payloads, webhook signing secrets, command
320
- environment values, or channel targets.
410
+ The status contract reports storage runtime, event, channel, delivery, file, and
411
+ transport metadata only. It does not include event payloads, webhook signing
412
+ secrets, command environment values, or channel targets.
321
413
 
322
414
  Use `--json` for script-friendly output and `--dir <path>` for isolated data.
323
415
 
@@ -0,0 +1,136 @@
1
+ import type { EventData, EventEnvelope } from "./types.js";
2
+ export interface EventValidationIssue {
3
+ path: string;
4
+ message: string;
5
+ }
6
+ export type EventValidationResult = {
7
+ ok: true;
8
+ } | {
9
+ ok: false;
10
+ issues: EventValidationIssue[];
11
+ };
12
+ export type EventDataValidator = (data: EventData, event: EventEnvelope) => EventValidationResult;
13
+ export interface EventTypeDefinition {
14
+ /** Envelope `type` string this definition binds, e.g. `release.published`. */
15
+ type: string;
16
+ /** `@hasna/contracts` schema id the payload mirrors, e.g. `hasna.release.v1`. */
17
+ contractSchemaId?: string;
18
+ description?: string;
19
+ validate: EventDataValidator;
20
+ }
21
+ export declare class EventValidationError extends Error {
22
+ readonly eventType: string;
23
+ readonly issues: EventValidationIssue[];
24
+ constructor(eventType: string, issues: EventValidationIssue[]);
25
+ }
26
+ export declare class EventTypeCatalog {
27
+ private definitions;
28
+ register(definition: EventTypeDefinition): this;
29
+ unregister(type: string): boolean;
30
+ has(type: string): boolean;
31
+ get(type: string): EventTypeDefinition | undefined;
32
+ list(): EventTypeDefinition[];
33
+ /**
34
+ * Validate an event against its registered definition. Events whose type is
35
+ * NOT registered always pass: free-form types stay untouched.
36
+ */
37
+ validateEvent(event: EventEnvelope): EventValidationResult;
38
+ /** Like {@link validateEvent} but throws {@link EventValidationError}. */
39
+ assertEventValid(event: EventEnvelope): void;
40
+ }
41
+ /** Shared default catalog used by `EventsClient` when none is provided. */
42
+ export declare const defaultEventTypeCatalog: EventTypeCatalog;
43
+ export declare const DISTRIBUTION_EVENT_TYPES: {
44
+ readonly releasePublished: "release.published";
45
+ readonly rolloutStarted: "release.rollout.started";
46
+ readonly rolloutCompleted: "release.rollout.completed";
47
+ readonly rolloutFailed: "release.rollout.failed";
48
+ readonly appInstalled: "app.installed";
49
+ readonly announcementSent: "announcement.sent";
50
+ readonly feedbackCreated: "feedback.created";
51
+ readonly feedbackTriaged: "feedback.triaged";
52
+ };
53
+ export type DistributionEventType = (typeof DISTRIBUTION_EVENT_TYPES)[keyof typeof DISTRIBUTION_EVENT_TYPES];
54
+ /** Contracts schema id each distribution event payload mirrors. */
55
+ export declare const DISTRIBUTION_EVENT_CONTRACT_SCHEMAS: Record<DistributionEventType, string>;
56
+ export type PublishPath = "skill" | "ci" | "backfilled";
57
+ export type RolloutAction = "install" | "update" | "rollback" | "freeze-blocked";
58
+ /** Payload for `release.published`; mirrors `hasna.release.v1` key fields. */
59
+ export type ReleasePublishedData = {
60
+ appId: string;
61
+ package: string;
62
+ version: string;
63
+ gitSha?: string;
64
+ publishedAt?: string;
65
+ publishPath?: PublishPath;
66
+ changelogRef?: string;
67
+ [key: string]: unknown;
68
+ };
69
+ /** Payload for `release.rollout.*`; mirrors `hasna.rollout_record.v1` key fields. */
70
+ export type RolloutData = {
71
+ appId: string;
72
+ package: string;
73
+ version: string;
74
+ machine: string;
75
+ action?: RolloutAction;
76
+ result?: string;
77
+ error?: string;
78
+ [key: string]: unknown;
79
+ };
80
+ /** Payload for `app.installed`; mirrors `hasna.rollout_record.v1` (action install). */
81
+ export type AppInstalledData = {
82
+ appId: string;
83
+ package: string;
84
+ version: string;
85
+ machine: string;
86
+ [key: string]: unknown;
87
+ };
88
+ /** Payload for `announcement.sent`; mirrors `hasna.announcement.v1` key fields. */
89
+ export type AnnouncementSentData = {
90
+ campaignId: string;
91
+ appId?: string;
92
+ audienceId?: string;
93
+ releaseId?: string;
94
+ channels?: string[];
95
+ [key: string]: unknown;
96
+ };
97
+ /** Payload for `feedback.created`. */
98
+ export type FeedbackCreatedData = {
99
+ feedbackId: string;
100
+ appId?: string;
101
+ source?: string;
102
+ summary?: string;
103
+ severity?: string;
104
+ [key: string]: unknown;
105
+ };
106
+ /** Payload for `feedback.triaged`. */
107
+ export type FeedbackTriagedData = {
108
+ feedbackId: string;
109
+ disposition: string;
110
+ appId?: string;
111
+ triagedBy?: string;
112
+ [key: string]: unknown;
113
+ };
114
+ export type DistributionEventDataMap = {
115
+ "release.published": ReleasePublishedData;
116
+ "release.rollout.started": RolloutData;
117
+ "release.rollout.completed": RolloutData;
118
+ "release.rollout.failed": RolloutData;
119
+ "app.installed": AppInstalledData;
120
+ "announcement.sent": AnnouncementSentData;
121
+ "feedback.created": FeedbackCreatedData;
122
+ "feedback.triaged": FeedbackTriagedData;
123
+ };
124
+ export declare const validateReleasePublishedData: EventDataValidator;
125
+ export declare const validateRolloutData: EventDataValidator;
126
+ export declare const validateAppInstalledData: EventDataValidator;
127
+ export declare const validateAnnouncementSentData: EventDataValidator;
128
+ export declare const validateFeedbackCreatedData: EventDataValidator;
129
+ export declare const validateFeedbackTriagedData: EventDataValidator;
130
+ /** Fresh definitions for every distribution event type. */
131
+ export declare function createDistributionEventDefinitions(): EventTypeDefinition[];
132
+ /**
133
+ * Register the distribution event types on a catalog (the shared default
134
+ * catalog when omitted). Opt-in: nothing is registered until this is called.
135
+ */
136
+ export declare function registerDistributionEventTypes(catalog?: EventTypeCatalog): EventTypeCatalog;
@@ -0,0 +1,191 @@
1
+ // @bun
2
+ // src/catalog.ts
3
+ class EventValidationError extends Error {
4
+ eventType;
5
+ issues;
6
+ constructor(eventType, issues) {
7
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
8
+ super(`Event validation failed for type "${eventType}": ${detail}`);
9
+ this.name = "EventValidationError";
10
+ this.eventType = eventType;
11
+ this.issues = issues;
12
+ }
13
+ }
14
+
15
+ class EventTypeCatalog {
16
+ definitions = new Map;
17
+ register(definition) {
18
+ this.definitions.set(definition.type, definition);
19
+ return this;
20
+ }
21
+ unregister(type) {
22
+ return this.definitions.delete(type);
23
+ }
24
+ has(type) {
25
+ return this.definitions.has(type);
26
+ }
27
+ get(type) {
28
+ return this.definitions.get(type);
29
+ }
30
+ list() {
31
+ return [...this.definitions.values()];
32
+ }
33
+ validateEvent(event) {
34
+ const definition = this.definitions.get(event.type);
35
+ if (!definition)
36
+ return { ok: true };
37
+ return definition.validate(event.data, event);
38
+ }
39
+ assertEventValid(event) {
40
+ const result = this.validateEvent(event);
41
+ if (!result.ok) {
42
+ throw new EventValidationError(event.type, result.issues);
43
+ }
44
+ }
45
+ }
46
+ var defaultEventTypeCatalog = new EventTypeCatalog;
47
+ var DISTRIBUTION_EVENT_TYPES = {
48
+ releasePublished: "release.published",
49
+ rolloutStarted: "release.rollout.started",
50
+ rolloutCompleted: "release.rollout.completed",
51
+ rolloutFailed: "release.rollout.failed",
52
+ appInstalled: "app.installed",
53
+ announcementSent: "announcement.sent",
54
+ feedbackCreated: "feedback.created",
55
+ feedbackTriaged: "feedback.triaged"
56
+ };
57
+ var DISTRIBUTION_EVENT_CONTRACT_SCHEMAS = {
58
+ "release.published": "hasna.release.v1",
59
+ "release.rollout.started": "hasna.rollout_record.v1",
60
+ "release.rollout.completed": "hasna.rollout_record.v1",
61
+ "release.rollout.failed": "hasna.rollout_record.v1",
62
+ "app.installed": "hasna.rollout_record.v1",
63
+ "announcement.sent": "hasna.announcement.v1",
64
+ "feedback.created": "hasna.feedback.v1",
65
+ "feedback.triaged": "hasna.feedback.v1"
66
+ };
67
+ var PUBLISH_PATHS = ["skill", "ci", "backfilled"];
68
+ var ROLLOUT_ACTIONS = ["install", "update", "rollback", "freeze-blocked"];
69
+ function requireString(data, key, issues) {
70
+ const value = data[key];
71
+ if (typeof value !== "string" || value.trim().length === 0) {
72
+ issues.push({ path: key, message: "must be a non-empty string" });
73
+ }
74
+ }
75
+ function optionalString(data, key, issues) {
76
+ const value = data[key];
77
+ if (value !== undefined && (typeof value !== "string" || value.trim().length === 0)) {
78
+ issues.push({ path: key, message: "must be a non-empty string when present" });
79
+ }
80
+ }
81
+ function optionalEnum(data, key, allowed, issues) {
82
+ const value = data[key];
83
+ if (value !== undefined && (typeof value !== "string" || !allowed.includes(value))) {
84
+ issues.push({ path: key, message: `must be one of: ${allowed.join(", ")}` });
85
+ }
86
+ }
87
+ function optionalStringArray(data, key, issues) {
88
+ const value = data[key];
89
+ if (value === undefined)
90
+ return;
91
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
92
+ issues.push({ path: key, message: "must be an array of non-empty strings when present" });
93
+ }
94
+ }
95
+ function toResult(issues) {
96
+ return issues.length === 0 ? { ok: true } : { ok: false, issues };
97
+ }
98
+ var validateReleasePublishedData = (data) => {
99
+ const issues = [];
100
+ requireString(data, "appId", issues);
101
+ requireString(data, "package", issues);
102
+ requireString(data, "version", issues);
103
+ optionalString(data, "gitSha", issues);
104
+ optionalString(data, "publishedAt", issues);
105
+ optionalEnum(data, "publishPath", PUBLISH_PATHS, issues);
106
+ return toResult(issues);
107
+ };
108
+ var validateRolloutData = (data, event) => {
109
+ const issues = [];
110
+ requireString(data, "appId", issues);
111
+ requireString(data, "package", issues);
112
+ requireString(data, "version", issues);
113
+ requireString(data, "machine", issues);
114
+ optionalEnum(data, "action", ROLLOUT_ACTIONS, issues);
115
+ if (event.type === "release.rollout.completed" || event.type === "release.rollout.failed") {
116
+ requireString(data, "result", issues);
117
+ }
118
+ return toResult(issues);
119
+ };
120
+ var validateAppInstalledData = (data) => {
121
+ const issues = [];
122
+ requireString(data, "appId", issues);
123
+ requireString(data, "package", issues);
124
+ requireString(data, "version", issues);
125
+ requireString(data, "machine", issues);
126
+ return toResult(issues);
127
+ };
128
+ var validateAnnouncementSentData = (data) => {
129
+ const issues = [];
130
+ requireString(data, "campaignId", issues);
131
+ optionalString(data, "appId", issues);
132
+ optionalString(data, "audienceId", issues);
133
+ optionalString(data, "releaseId", issues);
134
+ optionalStringArray(data, "channels", issues);
135
+ return toResult(issues);
136
+ };
137
+ var validateFeedbackCreatedData = (data) => {
138
+ const issues = [];
139
+ requireString(data, "feedbackId", issues);
140
+ optionalString(data, "appId", issues);
141
+ optionalString(data, "source", issues);
142
+ optionalString(data, "summary", issues);
143
+ return toResult(issues);
144
+ };
145
+ var validateFeedbackTriagedData = (data) => {
146
+ const issues = [];
147
+ requireString(data, "feedbackId", issues);
148
+ requireString(data, "disposition", issues);
149
+ optionalString(data, "appId", issues);
150
+ optionalString(data, "triagedBy", issues);
151
+ return toResult(issues);
152
+ };
153
+ function createDistributionEventDefinitions() {
154
+ const bind = (type, validate, description) => ({
155
+ type,
156
+ contractSchemaId: DISTRIBUTION_EVENT_CONTRACT_SCHEMAS[type],
157
+ description,
158
+ validate
159
+ });
160
+ return [
161
+ bind("release.published", validateReleasePublishedData, "A package version was published"),
162
+ bind("release.rollout.started", validateRolloutData, "A rollout of a release to a machine started"),
163
+ bind("release.rollout.completed", validateRolloutData, "A rollout of a release to a machine completed"),
164
+ bind("release.rollout.failed", validateRolloutData, "A rollout of a release to a machine failed"),
165
+ bind("app.installed", validateAppInstalledData, "An app was installed on a machine"),
166
+ bind("announcement.sent", validateAnnouncementSentData, "An announcement campaign was sent"),
167
+ bind("feedback.created", validateFeedbackCreatedData, "User or agent feedback was captured"),
168
+ bind("feedback.triaged", validateFeedbackTriagedData, "Captured feedback was triaged")
169
+ ];
170
+ }
171
+ function registerDistributionEventTypes(catalog = defaultEventTypeCatalog) {
172
+ for (const definition of createDistributionEventDefinitions()) {
173
+ catalog.register(definition);
174
+ }
175
+ return catalog;
176
+ }
177
+ export {
178
+ validateRolloutData,
179
+ validateReleasePublishedData,
180
+ validateFeedbackTriagedData,
181
+ validateFeedbackCreatedData,
182
+ validateAppInstalledData,
183
+ validateAnnouncementSentData,
184
+ registerDistributionEventTypes,
185
+ defaultEventTypeCatalog,
186
+ createDistributionEventDefinitions,
187
+ EventValidationError,
188
+ EventTypeCatalog,
189
+ DISTRIBUTION_EVENT_TYPES,
190
+ DISTRIBUTION_EVENT_CONTRACT_SCHEMAS
191
+ };