@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/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
|
|
80
|
+
async appendEventOnce(event, options = {}) {
|
|
75
81
|
await this.init();
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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"
|