@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/storage.js CHANGED
@@ -1,11 +1,15 @@
1
1
  // @bun
2
2
  // src/storage.ts
3
3
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
4
+ import { Buffer } from "buffer";
4
5
  import { existsSync } from "fs";
5
6
  import { homedir } from "os";
6
7
  import { join } from "path";
7
8
  var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
8
9
  var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
10
+ var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
11
+ var DEFAULT_EVENT_PAGE_LIMIT = 100;
12
+ var MAX_EVENT_PAGE_LIMIT = 1000;
9
13
  function getEventsDataDir(override) {
10
14
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
11
15
  }
@@ -19,11 +23,13 @@ function getActiveEventsDirEnv() {
19
23
 
20
24
  class JsonEventsStore {
21
25
  dataDir;
26
+ runtime;
22
27
  channelsPath;
23
28
  eventsPath;
24
29
  deliveriesPath;
25
30
  constructor(dataDir = getEventsDataDir()) {
26
31
  this.dataDir = dataDir;
32
+ this.runtime = localJsonRuntime(dataDir);
27
33
  this.channelsPath = join(dataDir, "channels.json");
28
34
  this.eventsPath = join(dataDir, "events.json");
29
35
  this.deliveriesPath = join(dataDir, "deliveries.json");
@@ -71,13 +77,58 @@ class JsonEventsStore {
71
77
  await this.writeJson(this.eventsPath, events);
72
78
  return event;
73
79
  }
74
- async listEvents() {
80
+ async appendEventOnce(event, options = {}) {
75
81
  await this.init();
76
- return this.readJson(this.eventsPath, []);
82
+ const events = await this.readJson(this.eventsPath, []);
83
+ const dedupe = options.dedupe !== false;
84
+ if (dedupe) {
85
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
86
+ if (existing) {
87
+ return {
88
+ event: existing,
89
+ stored: false,
90
+ deduped: true,
91
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
92
+ };
93
+ }
94
+ }
95
+ events.push(event);
96
+ await this.writeJson(this.eventsPath, events);
97
+ return {
98
+ event,
99
+ stored: true,
100
+ deduped: false,
101
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
102
+ };
103
+ }
104
+ async listEvents(options = {}) {
105
+ await this.init();
106
+ const events = await this.readJson(this.eventsPath, []);
107
+ return queryEvents(events, options);
108
+ }
109
+ async listEventsPage(options = {}) {
110
+ await this.init();
111
+ const events = await this.readJson(this.eventsPath, []);
112
+ const queried = queryEvents(events, {
113
+ eventId: options.eventId,
114
+ source: options.source,
115
+ type: options.type
116
+ });
117
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
118
+ const limit = normalizeEventPageLimit(options.limit);
119
+ const pageEvents = queried.slice(offset, offset + limit);
120
+ const nextOffset = offset + pageEvents.length;
121
+ const hasMore = nextOffset < queried.length;
122
+ return {
123
+ events: pageEvents,
124
+ cursor: options.cursor,
125
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
126
+ hasMore
127
+ };
77
128
  }
78
129
  async findEventByIdentity(identity) {
79
130
  const events = await this.listEvents();
80
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
131
+ return findEventByIdentity(events, identity);
81
132
  }
82
133
  async appendDelivery(result) {
83
134
  await this.init();
@@ -128,6 +179,83 @@ class JsonEventsStore {
128
179
  });
129
180
  }
130
181
  }
182
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
183
+ return {
184
+ mode: "local-files",
185
+ name: "json-events-store",
186
+ remote: false,
187
+ localFiles: true,
188
+ localSqlite: false,
189
+ postgres: false,
190
+ s3: false,
191
+ aws: false,
192
+ durable: true,
193
+ idempotency: "best-effort-local",
194
+ replayCursors: true,
195
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
196
+ };
197
+ }
198
+ function encodeLocalJsonEventCursor(offset, options = {}) {
199
+ if (!Number.isInteger(offset) || offset < 0)
200
+ throw new Error(`Invalid event cursor offset: ${offset}`);
201
+ const payload = {
202
+ offset,
203
+ eventId: options.eventId,
204
+ source: options.source,
205
+ type: options.type
206
+ };
207
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
208
+ }
209
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
210
+ if (!cursor)
211
+ return 0;
212
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
213
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
214
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
215
+ let payload;
216
+ try {
217
+ payload = JSON.parse(Buffer.from(rawPayload, "base64url").toString("utf-8"));
218
+ } catch {
219
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
220
+ }
221
+ const offset = payload.offset;
222
+ if (!Number.isInteger(offset) || offset < 0)
223
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
224
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
225
+ assertCursorFilter("source", payload.source, options.source);
226
+ assertCursorFilter("type", payload.type, options.type);
227
+ return offset;
228
+ }
229
+ function normalizeEventPageLimit(limit) {
230
+ if (limit === undefined)
231
+ return DEFAULT_EVENT_PAGE_LIMIT;
232
+ if (!Number.isInteger(limit) || limit < 1)
233
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
234
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
235
+ }
236
+ function queryEvents(events, options) {
237
+ let rows = events;
238
+ if (options.eventId)
239
+ rows = rows.filter((event) => event.id === options.eventId);
240
+ if (options.source)
241
+ rows = rows.filter((event) => event.source === options.source);
242
+ if (options.type)
243
+ rows = rows.filter((event) => event.type === options.type);
244
+ if (options.cursor) {
245
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
246
+ rows = rows.slice(offset);
247
+ }
248
+ if (options.limit !== undefined)
249
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
250
+ return rows;
251
+ }
252
+ function assertCursorFilter(name, cursorValue, optionValue) {
253
+ if (cursorValue !== optionValue)
254
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
255
+ }
256
+ function findEventByIdentity(events, identity) {
257
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
258
+ }
131
259
  async function getEventsStatus(dataDir) {
132
260
  const store = new JsonEventsStore(dataDir);
133
261
  await store.init();
@@ -144,6 +272,7 @@ async function getEventsStatus(dataDir) {
144
272
  service: "events",
145
273
  schemaVersion: "1.0",
146
274
  dataDir: store.dataDir,
275
+ storage: store.runtime,
147
276
  env: {
148
277
  primary: HASNA_EVENTS_DIR_ENV,
149
278
  fallback: HASNA_EVENTS_HOME_ENV,
@@ -175,10 +304,17 @@ function statusFile(dataDir, fileName, records) {
175
304
  return { path, exists: existsSync(path), records };
176
305
  }
177
306
  export {
307
+ normalizeEventPageLimit,
308
+ localJsonRuntime,
178
309
  getEventsStatus,
179
310
  getEventsDataDir,
180
311
  getActiveEventsDirEnv,
312
+ encodeLocalJsonEventCursor,
313
+ decodeLocalJsonEventCursor,
314
+ MAX_EVENT_PAGE_LIMIT,
315
+ LOCAL_JSON_EVENT_CURSOR_PREFIX,
181
316
  JsonEventsStore,
182
317
  HASNA_EVENTS_HOME_ENV,
183
- HASNA_EVENTS_DIR_ENV
318
+ HASNA_EVENTS_DIR_ENV,
319
+ DEFAULT_EVENT_PAGE_LIMIT
184
320
  };
package/dist/types.d.ts CHANGED
@@ -105,13 +105,50 @@ export interface EmitOptions {
105
105
  deliver?: boolean;
106
106
  dedupe?: boolean;
107
107
  redactSensitiveData?: boolean;
108
- }
109
- export interface ReplayOptions {
108
+ /**
109
+ * Per-emit override for the opt-in catalog validator hook. When true the
110
+ * event is validated against the client's `EventTypeCatalog` before it is
111
+ * stored or delivered (types not registered in the catalog always pass);
112
+ * when false validation is skipped even if the client enabled
113
+ * `validateCatalogTypes`. Defaults to the client-level setting (off).
114
+ */
115
+ validate?: boolean;
116
+ }
117
+ export interface EventPageOptions {
110
118
  eventId?: string;
111
119
  source?: string;
112
120
  type?: string;
121
+ cursor?: string;
122
+ limit?: number;
123
+ }
124
+ export interface EventPage {
125
+ events: EventEnvelope[];
126
+ cursor?: string;
127
+ nextCursor?: string;
128
+ hasMore: boolean;
129
+ }
130
+ export interface EventAppendOptions {
131
+ dedupe?: boolean;
132
+ }
133
+ export interface EventAppendResult<TData extends EventData = EventData> {
134
+ event: EventEnvelope<TData>;
135
+ stored: boolean;
136
+ deduped: boolean;
137
+ identity: {
138
+ id: string;
139
+ dedupeKey?: string;
140
+ };
141
+ }
142
+ export interface ReplayOptions extends EventPageOptions {
113
143
  dryRun?: boolean;
114
144
  }
145
+ export interface ReplayResult {
146
+ events: EventEnvelope[];
147
+ deliveries: DeliveryResult[];
148
+ cursor?: string;
149
+ nextCursor?: string;
150
+ hasMore: boolean;
151
+ }
115
152
  export interface StoredEventsData {
116
153
  channels: ChannelConfig[];
117
154
  events: EventEnvelope[];
@@ -122,10 +159,26 @@ export interface EmitResult<TData extends EventData = EventData> {
122
159
  deliveries: DeliveryResult[];
123
160
  deduped: boolean;
124
161
  }
162
+ export type EventsStorageMode = "local-files" | "local-sqlite" | "remote-postgres" | "remote-s3" | "remote-aws" | "custom";
163
+ export interface EventsStoreRuntime {
164
+ mode: EventsStorageMode;
165
+ name: string;
166
+ remote: boolean;
167
+ localFiles: boolean;
168
+ localSqlite: boolean;
169
+ postgres: boolean;
170
+ s3: boolean;
171
+ aws: boolean;
172
+ durable: boolean;
173
+ idempotency: "best-effort-local" | "atomic-store" | "consumer-owned";
174
+ replayCursors: boolean;
175
+ description: string;
176
+ }
125
177
  export interface EventsStatus {
126
178
  service: "events";
127
179
  schemaVersion: "1.0";
128
180
  dataDir: string;
181
+ storage: EventsStoreRuntime;
129
182
  env: {
130
183
  primary: "HASNA_EVENTS_DIR";
131
184
  fallback: "HASNA_EVENTS_HOME";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/events",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Shared event envelopes, local channels, replay, and delivery transports for Hasna open-source apps",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,6 +30,10 @@
30
30
  "types": "./dist/transports.d.ts",
31
31
  "import": "./dist/transports.js"
32
32
  },
33
+ "./catalog": {
34
+ "types": "./dist/catalog.d.ts",
35
+ "import": "./dist/catalog.js"
36
+ },
33
37
  "./commander": {
34
38
  "types": "./dist/commander.d.ts",
35
39
  "import": "./dist/commander.js"
@@ -45,7 +49,7 @@
45
49
  "LICENSE"
46
50
  ],
47
51
  "scripts": {
48
- "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/index.ts src/storage.ts src/signing.ts src/filter.ts src/transports.ts src/types.ts src/commander.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
52
+ "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/index.ts src/storage.ts src/signing.ts src/filter.ts src/transports.ts src/types.ts src/commander.ts src/catalog.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
49
53
  "typecheck": "tsc --noEmit",
50
54
  "test": "bun test",
51
55
  "prepublishOnly": "bun run test && bun run build"