@fonderie/events 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fonderie, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @fonderie/events
2
+
3
+ The event bus wiring the bricks together: publish domain events, subscribe
4
+ with wildcard patterns, and swap transports without touching handlers.
5
+ Memory and PostgreSQL transports built in.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @fonderie/events
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```ts
16
+ import { FonderieApp, defineConfig } from '@fonderie/core';
17
+ import { EventsModule } from '@fonderie/events';
18
+
19
+ const app = await new FonderieApp(defineConfig({}))
20
+ .register(new EventsModule())
21
+ .boot();
22
+ ```
23
+
24
+ ```ts
25
+ import { EventBus, matchesPattern, NOTIFICATION_EVENT } from '@fonderie/events';
26
+ ```
27
+
28
+ Domain packages export typed `EVENT_KEYS`; alias them on import
29
+ (`import { EVENT_KEYS as AUTH_EVENTS } from '@fonderie/auth'`) and
30
+ subscribe to exactly the events you care about.
31
+
32
+ ## Why this exists
33
+
34
+ You've shipped this plumbing before — auth, teams, billing, messaging —
35
+ and the next project will ask for it again. Fonderie packages it once:
36
+ plain TypeScript modules for
37
+ [`@fonderie/core`](https://github.com/fonderie-js/sdk/tree/main/packages/core),
38
+ PostgreSQL-backed, self-hosted, MIT. No external control plane, no
39
+ per-seat anything. Register the modules you need; skip the ones you don't.
40
+
41
+ **This package owns** how the bricks talk. The publish/subscribe backbone that lets
42
+ producers and consumers stay decoupled — swap transports, keep handlers.
43
+
44
+ Browse the whole set at
45
+ [fonderie-js/sdk](https://github.com/fonderie-js/sdk) · follow
46
+ [@fonderiejs](https://x.com/fonderiejs)
47
+
48
+ ## License
49
+
50
+ MIT © Fonderie, Inc.
package/dist/index.cjs ADDED
@@ -0,0 +1,286 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ EventBus: () => EventBus,
34
+ EventsModule: () => EventsModule,
35
+ MemoryTransport: () => MemoryTransport,
36
+ NOTIFICATION_EVENT: () => NOTIFICATION_EVENT,
37
+ PGTransport: () => PGTransport,
38
+ matchesPattern: () => matchesPattern
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/bus.ts
43
+ var import_node_crypto = require("crypto");
44
+ var EventBus = class {
45
+ constructor(transport) {
46
+ this.transport = transport;
47
+ }
48
+ transport;
49
+ async emit(type, payload, opts) {
50
+ const meta = {
51
+ id: (0, import_node_crypto.randomUUID)(),
52
+ type,
53
+ emittedAt: (/* @__PURE__ */ new Date()).toISOString(),
54
+ attempts: 0,
55
+ ...opts?.requestId !== void 0 ? { requestId: opts.requestId } : {}
56
+ };
57
+ await this.transport.publish(type, payload, meta);
58
+ }
59
+ // consumer identifies the logical subscriber for per-consumer delivery tracking.
60
+ // Defaults to the pattern string — stable and predictable for single-subscriber patterns.
61
+ on(type, handler, consumer = type) {
62
+ this.transport.subscribe(type, handler, consumer);
63
+ }
64
+ async start() {
65
+ await this.transport.start();
66
+ }
67
+ async stop() {
68
+ await this.transport.stop();
69
+ }
70
+ };
71
+
72
+ // src/transports/pg.ts
73
+ var import_pg = __toESM(require("pg"), 1);
74
+ var import_store = require("@fonderie/store");
75
+
76
+ // src/transports/pattern.ts
77
+ function matchesPattern(pattern, eventType) {
78
+ if (pattern === "*") return true;
79
+ const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
80
+ return regex.test(eventType);
81
+ }
82
+
83
+ // src/transports/pg.ts
84
+ var PGTransport = class {
85
+ constructor(config) {
86
+ this.config = config;
87
+ this.maxRetries = config.maxRetries ?? 3;
88
+ this.batchSize = config.batchSize ?? 10;
89
+ this.pollInterval = config.pollInterval ?? 1e3;
90
+ }
91
+ config;
92
+ subscriptions = [];
93
+ listenClient = null;
94
+ store;
95
+ running = false;
96
+ wakeResolvers = [];
97
+ maxRetries;
98
+ batchSize;
99
+ pollInterval;
100
+ // ── Public API ──────────────────────────────────────────────────
101
+ subscribe(pattern, handler, consumer) {
102
+ this.subscriptions.push({ pattern, handler, consumer });
103
+ }
104
+ async publish(type, payload, meta) {
105
+ await this.store.query(
106
+ `INSERT INTO fonderie_events (id, type, payload, meta)
107
+ VALUES ($1, $2, $3, $4)`,
108
+ [meta.id, type, JSON.stringify(payload), JSON.stringify(meta)]
109
+ );
110
+ const consumers = this.matchingConsumers(type);
111
+ if (consumers.length > 0) {
112
+ await this.store.query(
113
+ `INSERT INTO fonderie_event_consumers (event_id, consumer, status, attempts)
114
+ SELECT $1, unnest($2::text[]), 'pending', 0
115
+ ON CONFLICT (event_id, consumer) DO NOTHING`,
116
+ [meta.id, consumers]
117
+ );
118
+ }
119
+ await this.store.query(`SELECT pg_notify('fonderie_events', '')`);
120
+ }
121
+ async start() {
122
+ this.running = true;
123
+ this.store = new import_store.PGAdapter(this.config.connectionUrl);
124
+ await this.store.query(
125
+ `UPDATE fonderie_event_consumers SET status = 'failed' WHERE status = 'processing'`
126
+ );
127
+ this.listenClient = new import_pg.default.Client(this.config.connectionUrl);
128
+ await this.listenClient.connect();
129
+ await this.listenClient.query("LISTEN fonderie_events");
130
+ this.listenClient.on("notification", () => this.wake());
131
+ this.listenClient.on(
132
+ "error",
133
+ (err) => console.error("[events:pg] listen client error:", err.message)
134
+ );
135
+ this.runPollLoop().catch((err) => console.error("[events:pg] poll loop crashed:", err));
136
+ }
137
+ async stop() {
138
+ this.running = false;
139
+ this.wake();
140
+ await this.listenClient?.end();
141
+ this.listenClient = null;
142
+ }
143
+ // ── Poll loop ───────────────────────────────────────────────────
144
+ async runPollLoop() {
145
+ while (this.running) {
146
+ try {
147
+ const hadWork = await this.pollAllConsumers();
148
+ if (!hadWork) await this.sleep();
149
+ } catch (err) {
150
+ console.error("[events:pg] poll error:", err);
151
+ await this.sleep();
152
+ }
153
+ }
154
+ }
155
+ async pollAllConsumers() {
156
+ const consumers = [...new Set(this.subscriptions.map((s) => s.consumer))];
157
+ const results = await Promise.all(consumers.map((c) => this.pollConsumer(c)));
158
+ return results.some((n) => n > 0);
159
+ }
160
+ async pollConsumer(consumer) {
161
+ const claimed = await this.store.query(
162
+ `UPDATE fonderie_event_consumers c
163
+ SET status = 'processing', attempts = c.attempts + 1
164
+ FROM (
165
+ SELECT event_id
166
+ FROM fonderie_event_consumers
167
+ WHERE consumer = $1
168
+ AND status IN ('pending', 'failed')
169
+ AND attempts < $2
170
+ ORDER BY event_id
171
+ LIMIT $3
172
+ FOR UPDATE SKIP LOCKED
173
+ ) AS locked
174
+ WHERE c.event_id = locked.event_id
175
+ AND c.consumer = $1
176
+ RETURNING c.event_id`,
177
+ [consumer, this.maxRetries, this.batchSize]
178
+ );
179
+ await Promise.all(claimed.map((row) => this.processConsumerEvent(consumer, row.event_id)));
180
+ return claimed.length;
181
+ }
182
+ // ── Event processing ────────────────────────────────────────────
183
+ async processConsumerEvent(consumer, eventId) {
184
+ const [event] = await this.store.query(
185
+ `SELECT type, payload, meta FROM fonderie_events WHERE id = $1`,
186
+ [eventId]
187
+ );
188
+ if (!event) return;
189
+ const handlers = this.subscriptions.filter((s) => s.consumer === consumer && matchesPattern(s.pattern, event.type)).map((s) => s.handler);
190
+ try {
191
+ await Promise.all(handlers.map((h) => h(event.payload, event.meta)));
192
+ await this.store.query(
193
+ `UPDATE fonderie_event_consumers
194
+ SET status = 'processed', processed_at = now()
195
+ WHERE event_id = $1 AND consumer = $2`,
196
+ [eventId, consumer]
197
+ );
198
+ } catch (err) {
199
+ await this.store.query(
200
+ `UPDATE fonderie_event_consumers
201
+ SET status = CASE WHEN attempts >= $1 THEN 'dead' ELSE 'failed' END,
202
+ error = $2
203
+ WHERE event_id = $3 AND consumer = $4`,
204
+ [this.maxRetries, err instanceof Error ? err.message : String(err), eventId, consumer]
205
+ );
206
+ }
207
+ }
208
+ // ── Helpers ─────────────────────────────────────────────────────
209
+ matchingConsumers(eventType) {
210
+ const seen = /* @__PURE__ */ new Set();
211
+ for (const sub of this.subscriptions) {
212
+ if (matchesPattern(sub.pattern, eventType)) seen.add(sub.consumer);
213
+ }
214
+ return [...seen];
215
+ }
216
+ sleep() {
217
+ return new Promise((resolve) => {
218
+ let timer;
219
+ const wake = () => {
220
+ clearTimeout(timer);
221
+ resolve();
222
+ };
223
+ timer = setTimeout(() => {
224
+ const idx = this.wakeResolvers.indexOf(wake);
225
+ if (idx !== -1) this.wakeResolvers.splice(idx, 1);
226
+ resolve();
227
+ }, this.pollInterval);
228
+ this.wakeResolvers.push(wake);
229
+ });
230
+ }
231
+ wake() {
232
+ this.wakeResolvers.shift()?.();
233
+ }
234
+ };
235
+
236
+ // src/module.ts
237
+ function resolveTransport(config) {
238
+ if ("type" in config && config.type === "pg") {
239
+ return new PGTransport({
240
+ connectionUrl: config.connectionUrl,
241
+ ...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {},
242
+ ...config.batchSize !== void 0 ? { batchSize: config.batchSize } : {},
243
+ ...config.pollInterval !== void 0 ? { pollInterval: config.pollInterval } : {}
244
+ });
245
+ }
246
+ return config;
247
+ }
248
+ var EventsModule = class {
249
+ name = "@fonderie/events";
250
+ bus;
251
+ constructor(config) {
252
+ this.bus = new EventBus(resolveTransport(config.transport));
253
+ }
254
+ install(_app) {
255
+ this.bus.start().catch((err) => console.error("[events] failed to start transport", err));
256
+ }
257
+ };
258
+
259
+ // src/transports/memory.ts
260
+ var MemoryTransport = class {
261
+ subscriptions = [];
262
+ async publish(type, payload, meta) {
263
+ const matching = this.subscriptions.filter((s) => matchesPattern(s.pattern, type));
264
+ await Promise.all(matching.map((s) => s.handler(payload, meta)));
265
+ }
266
+ subscribe(pattern, handler, _consumer) {
267
+ this.subscriptions.push({ pattern, handler });
268
+ }
269
+ async start() {
270
+ }
271
+ async stop() {
272
+ }
273
+ };
274
+
275
+ // src/index.ts
276
+ var NOTIFICATION_EVENT = "fonderie.notification.send";
277
+ // Annotate the CommonJS export names for ESM import in node:
278
+ 0 && (module.exports = {
279
+ EventBus,
280
+ EventsModule,
281
+ MemoryTransport,
282
+ NOTIFICATION_EVENT,
283
+ PGTransport,
284
+ matchesPattern
285
+ });
286
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/bus.ts","../src/transports/pg.ts","../src/transports/pattern.ts","../src/module.ts","../src/transports/memory.ts"],"sourcesContent":["export { EventBus } from './bus';\nexport { EventsModule } from './module';\nexport type { IEventsConfig, EventTransportConfig } from './module';\n\nexport { MemoryTransport, PGTransport } from './transports';\nexport type { IEventTransport, IPGTransportConfig } from './transports';\n\nexport { matchesPattern } from './transports/pattern';\n\nexport type { IEventMeta, IEventHandler, IEventRecord, IConsumerRecord } from './types';\n\n// ── Typed event keys ─────────────────────────────────────────────\n// Each domain package re-exports its own EVENT_KEYS.\n// Consumers alias on import:\n// import { EVENT_KEYS as AUTH_EVENT_KEYS } from '@fonderie/auth'\n\nexport const NOTIFICATION_EVENT = 'fonderie.notification.send' as const;\nexport type NotificationEvent = typeof NOTIFICATION_EVENT;\n","import { randomUUID } from 'node:crypto';\n\nimport type { IEventTransport } from './transports/types';\nimport type { IEventMeta, IEventHandler } from './types';\n\nexport class EventBus {\n\tconstructor(private transport: IEventTransport) {}\n\n\tasync emit<T = unknown>(type: string, payload: T, opts?: { requestId?: string }): Promise<void> {\n\t\tconst meta: IEventMeta = {\n\t\t\tid: randomUUID(),\n\t\t\ttype,\n\t\t\temittedAt: new Date().toISOString(),\n\t\t\tattempts: 0,\n\t\t\t...(opts?.requestId !== undefined ? { requestId: opts.requestId } : {}),\n\t\t};\n\t\tawait this.transport.publish(type, payload, meta);\n\t}\n\n\t// consumer identifies the logical subscriber for per-consumer delivery tracking.\n\t// Defaults to the pattern string — stable and predictable for single-subscriber patterns.\n\ton<T = unknown>(type: string, handler: IEventHandler<T>, consumer: string = type): void {\n\t\tthis.transport.subscribe(type, handler as IEventHandler, consumer);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tawait this.transport.start();\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tawait this.transport.stop();\n\t}\n}\n","import pg from 'pg';\n\nimport { PGAdapter } from '@fonderie/store';\nimport type { IStoreAdapter } from '@fonderie/store';\nimport type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler, IEventRecord } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport interface IPGTransportConfig {\n\tconnectionUrl: string;\n\tmaxRetries?: number; // default 3\n\tbatchSize?: number; // default 10 rows claimed per consumer per poll cycle\n\tpollInterval?: number; // default 1000ms fallback poll when no NOTIFY arrives\n}\n\ninterface Subscription {\n\tpattern: string;\n\thandler: IEventHandler;\n\tconsumer: string;\n}\n\nexport class PGTransport implements IEventTransport {\n\tprivate subscriptions: Subscription[] = [];\n\tprivate listenClient: pg.Client | null = null;\n\tprivate store!: IStoreAdapter;\n\tprivate running = false;\n\tprivate wakeResolvers: Array<() => void> = [];\n\n\tprivate readonly maxRetries: number;\n\tprivate readonly batchSize: number;\n\tprivate readonly pollInterval: number;\n\n\tconstructor(private config: IPGTransportConfig) {\n\t\tthis.maxRetries = config.maxRetries ?? 3;\n\t\tthis.batchSize = config.batchSize ?? 10;\n\t\tthis.pollInterval = config.pollInterval ?? 1_000;\n\t}\n\n\t// ── Public API ──────────────────────────────────────────────────\n\n\tsubscribe(pattern: string, handler: IEventHandler, consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler, consumer });\n\t}\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_events (id, type, payload, meta)\n\t\t\t VALUES ($1, $2, $3, $4)`,\n\t\t\t[meta.id, type, JSON.stringify(payload), JSON.stringify(meta)],\n\t\t);\n\n\t\tconst consumers = this.matchingConsumers(type);\n\t\tif (consumers.length > 0) {\n\t\t\tawait this.store.query(\n\t\t\t\t`INSERT INTO fonderie_event_consumers (event_id, consumer, status, attempts)\n\t\t\t\t SELECT $1, unnest($2::text[]), 'pending', 0\n\t\t\t\t ON CONFLICT (event_id, consumer) DO NOTHING`,\n\t\t\t\t[meta.id, consumers],\n\t\t\t);\n\t\t}\n\n\t\t// NOTIFY carries no payload — it is a wake signal only\n\t\tawait this.store.query(`SELECT pg_notify('fonderie_events', '')`);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tthis.running = true;\n\t\tthis.store = new PGAdapter(this.config.connectionUrl);\n\n\t\t// Reset any rows left in 'processing' by a crashed instance\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_event_consumers SET status = 'failed' WHERE status = 'processing'`,\n\t\t);\n\n\t\tthis.listenClient = new pg.Client(this.config.connectionUrl);\n\t\tawait this.listenClient.connect();\n\t\tawait this.listenClient.query('LISTEN fonderie_events');\n\n\t\tthis.listenClient.on('notification', () => this.wake());\n\t\tthis.listenClient.on('error', (err) =>\n\t\t\tconsole.error('[events:pg] listen client error:', err.message),\n\t\t);\n\n\t\tthis.runPollLoop().catch((err) => console.error('[events:pg] poll loop crashed:', err));\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tthis.running = false;\n\t\tthis.wake();\n\t\tawait this.listenClient?.end();\n\t\tthis.listenClient = null;\n\t}\n\n\t// ── Poll loop ───────────────────────────────────────────────────\n\n\tprivate async runPollLoop(): Promise<void> {\n\t\twhile (this.running) {\n\t\t\ttry {\n\t\t\t\tconst hadWork = await this.pollAllConsumers();\n\t\t\t\tif (!hadWork) await this.sleep();\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[events:pg] poll error:', err);\n\t\t\t\tawait this.sleep();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async pollAllConsumers(): Promise<boolean> {\n\t\tconst consumers = [...new Set(this.subscriptions.map((s) => s.consumer))];\n\t\tconst results = await Promise.all(consumers.map((c) => this.pollConsumer(c)));\n\t\treturn results.some((n) => n > 0);\n\t}\n\n\tprivate async pollConsumer(consumer: string): Promise<number> {\n\t\tconst claimed = await this.store.query<{ event_id: string }>(\n\t\t\t`UPDATE fonderie_event_consumers c\n\t\t\t SET status = 'processing', attempts = c.attempts + 1\n\t\t\t FROM (\n\t\t\t SELECT event_id\n\t\t\t FROM fonderie_event_consumers\n\t\t\t WHERE consumer = $1\n\t\t\t AND status IN ('pending', 'failed')\n\t\t\t AND attempts < $2\n\t\t\t ORDER BY event_id\n\t\t\t LIMIT $3\n\t\t\t FOR UPDATE SKIP LOCKED\n\t\t\t ) AS locked\n\t\t\t WHERE c.event_id = locked.event_id\n\t\t\t AND c.consumer = $1\n\t\t\t RETURNING c.event_id`,\n\t\t\t[consumer, this.maxRetries, this.batchSize],\n\t\t);\n\n\t\tawait Promise.all(claimed.map((row) => this.processConsumerEvent(consumer, row.event_id)));\n\t\treturn claimed.length;\n\t}\n\n\t// ── Event processing ────────────────────────────────────────────\n\n\tprivate async processConsumerEvent(consumer: string, eventId: string): Promise<void> {\n\t\tconst [event] = await this.store.query<IEventRecord>(\n\t\t\t`SELECT type, payload, meta FROM fonderie_events WHERE id = $1`,\n\t\t\t[eventId],\n\t\t);\n\t\tif (!event) return;\n\n\t\tconst handlers = this.subscriptions\n\t\t\t.filter((s) => s.consumer === consumer && matchesPattern(s.pattern, event.type))\n\t\t\t.map((s) => s.handler);\n\n\t\ttry {\n\t\t\tawait Promise.all(handlers.map((h) => h(event.payload, event.meta)));\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = 'processed', processed_at = now()\n\t\t\t\t WHERE event_id = $1 AND consumer = $2`,\n\t\t\t\t[eventId, consumer],\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = CASE WHEN attempts >= $1 THEN 'dead' ELSE 'failed' END,\n\t\t\t\t error = $2\n\t\t\t\t WHERE event_id = $3 AND consumer = $4`,\n\t\t\t\t[this.maxRetries, err instanceof Error ? err.message : String(err), eventId, consumer],\n\t\t\t);\n\t\t}\n\t}\n\n\t// ── Helpers ─────────────────────────────────────────────────────\n\n\tprivate matchingConsumers(eventType: string): string[] {\n\t\tconst seen = new Set<string>();\n\t\tfor (const sub of this.subscriptions) {\n\t\t\tif (matchesPattern(sub.pattern, eventType)) seen.add(sub.consumer);\n\t\t}\n\t\treturn [...seen];\n\t}\n\n\tprivate sleep(): Promise<void> {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tlet timer: ReturnType<typeof setTimeout>;\n\t\t\tconst wake = () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\ttimer = setTimeout(() => {\n\t\t\t\tconst idx = this.wakeResolvers.indexOf(wake);\n\t\t\t\tif (idx !== -1) this.wakeResolvers.splice(idx, 1);\n\t\t\t\tresolve();\n\t\t\t}, this.pollInterval);\n\t\t\tthis.wakeResolvers.push(wake);\n\t\t});\n\t}\n\n\tprivate wake(): void {\n\t\tthis.wakeResolvers.shift()?.();\n\t}\n}\n","// Glob matching for event topic patterns.\n// '*' alone matches everything. Otherwise '*' is a wildcard for any\n// characters including dots, so 'sport.*' matches 'sport.event.created'.\nexport function matchesPattern(pattern: string, eventType: string): boolean {\n\tif (pattern === '*') return true;\n\tconst regex = new RegExp('^' + pattern.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*') + '$');\n\treturn regex.test(eventType);\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\n\nimport { EventBus } from './bus';\nimport { PGTransport } from './transports/pg';\nimport type { IEventTransport } from './transports/types';\n\nexport type EventTransportConfig =\n\t| {\n\t\t\ttype: 'pg';\n\t\t\tconnectionUrl: string;\n\t\t\tmaxRetries?: number;\n\t\t\tbatchSize?: number;\n\t\t\tpollInterval?: number;\n\t }\n\t| IEventTransport;\n\nexport interface IEventsConfig {\n\ttransport: EventTransportConfig;\n}\n\nfunction resolveTransport(config: EventTransportConfig): IEventTransport {\n\tif ('type' in config && config.type === 'pg') {\n\t\treturn new PGTransport({\n\t\t\tconnectionUrl: config.connectionUrl,\n\t\t\t...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),\n\t\t\t...(config.batchSize !== undefined ? { batchSize: config.batchSize } : {}),\n\t\t\t...(config.pollInterval !== undefined ? { pollInterval: config.pollInterval } : {}),\n\t\t});\n\t}\n\n\treturn config as IEventTransport;\n}\n\nexport class EventsModule implements IFonderieModule {\n\treadonly name = '@fonderie/events';\n\treadonly bus: EventBus;\n\n\tconstructor(config: IEventsConfig) {\n\t\tthis.bus = new EventBus(resolveTransport(config.transport));\n\t}\n\n\tinstall(_app: IFonderieApp): void {\n\t\tthis.bus.start().catch((err) => console.error('[events] failed to start transport', err));\n\t}\n}\n","import type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport class MemoryTransport implements IEventTransport {\n\tprivate subscriptions: Array<{ pattern: string; handler: IEventHandler }> = [];\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tconst matching = this.subscriptions.filter((s) => matchesPattern(s.pattern, type));\n\t\tawait Promise.all(matching.map((s) => s.handler(payload, meta)));\n\t}\n\n\tsubscribe(pattern: string, handler: IEventHandler, _consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler });\n\t}\n\n\tasync start(): Promise<void> {}\n\tasync stop(): Promise<void> {}\n}\n"],"mappings":"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","names":["pg"]}
@@ -0,0 +1,105 @@
1
+ import { IFonderieModule, IFonderieApp } from '@fonderie/core';
2
+
3
+ interface IEventMeta {
4
+ id: string;
5
+ type: string;
6
+ emittedAt: string;
7
+ attempts: number;
8
+ requestId?: string;
9
+ }
10
+ interface IEventRecord {
11
+ id: string;
12
+ type: string;
13
+ payload: Record<string, unknown>;
14
+ meta: IEventMeta;
15
+ created_at: Date;
16
+ }
17
+ interface IConsumerRecord {
18
+ event_id: string;
19
+ consumer: string;
20
+ status: 'pending' | 'processing' | 'processed' | 'failed' | 'dead';
21
+ attempts: number;
22
+ error: string | null;
23
+ processed_at: Date | null;
24
+ }
25
+ type IEventHandler<T = unknown> = (payload: T, meta: IEventMeta) => Promise<void>;
26
+
27
+ interface IEventTransport {
28
+ publish(type: string, payload: unknown, meta: IEventMeta): Promise<void>;
29
+ subscribe(type: string, handler: IEventHandler, consumer: string): void;
30
+ start(): Promise<void>;
31
+ stop(): Promise<void>;
32
+ }
33
+
34
+ declare class EventBus {
35
+ private transport;
36
+ constructor(transport: IEventTransport);
37
+ emit<T = unknown>(type: string, payload: T, opts?: {
38
+ requestId?: string;
39
+ }): Promise<void>;
40
+ on<T = unknown>(type: string, handler: IEventHandler<T>, consumer?: string): void;
41
+ start(): Promise<void>;
42
+ stop(): Promise<void>;
43
+ }
44
+
45
+ type EventTransportConfig = {
46
+ type: 'pg';
47
+ connectionUrl: string;
48
+ maxRetries?: number;
49
+ batchSize?: number;
50
+ pollInterval?: number;
51
+ } | IEventTransport;
52
+ interface IEventsConfig {
53
+ transport: EventTransportConfig;
54
+ }
55
+ declare class EventsModule implements IFonderieModule {
56
+ readonly name = "@fonderie/events";
57
+ readonly bus: EventBus;
58
+ constructor(config: IEventsConfig);
59
+ install(_app: IFonderieApp): void;
60
+ }
61
+
62
+ declare class MemoryTransport implements IEventTransport {
63
+ private subscriptions;
64
+ publish(type: string, payload: unknown, meta: IEventMeta): Promise<void>;
65
+ subscribe(pattern: string, handler: IEventHandler, _consumer: string): void;
66
+ start(): Promise<void>;
67
+ stop(): Promise<void>;
68
+ }
69
+
70
+ interface IPGTransportConfig {
71
+ connectionUrl: string;
72
+ maxRetries?: number;
73
+ batchSize?: number;
74
+ pollInterval?: number;
75
+ }
76
+ declare class PGTransport implements IEventTransport {
77
+ private config;
78
+ private subscriptions;
79
+ private listenClient;
80
+ private store;
81
+ private running;
82
+ private wakeResolvers;
83
+ private readonly maxRetries;
84
+ private readonly batchSize;
85
+ private readonly pollInterval;
86
+ constructor(config: IPGTransportConfig);
87
+ subscribe(pattern: string, handler: IEventHandler, consumer: string): void;
88
+ publish(type: string, payload: unknown, meta: IEventMeta): Promise<void>;
89
+ start(): Promise<void>;
90
+ stop(): Promise<void>;
91
+ private runPollLoop;
92
+ private pollAllConsumers;
93
+ private pollConsumer;
94
+ private processConsumerEvent;
95
+ private matchingConsumers;
96
+ private sleep;
97
+ private wake;
98
+ }
99
+
100
+ declare function matchesPattern(pattern: string, eventType: string): boolean;
101
+
102
+ declare const NOTIFICATION_EVENT: "fonderie.notification.send";
103
+ type NotificationEvent = typeof NOTIFICATION_EVENT;
104
+
105
+ export { EventBus, type EventTransportConfig, EventsModule, type IConsumerRecord, type IEventHandler, type IEventMeta, type IEventRecord, type IEventTransport, type IEventsConfig, type IPGTransportConfig, MemoryTransport, NOTIFICATION_EVENT, type NotificationEvent, PGTransport, matchesPattern };
@@ -0,0 +1,105 @@
1
+ import { IFonderieModule, IFonderieApp } from '@fonderie/core';
2
+
3
+ interface IEventMeta {
4
+ id: string;
5
+ type: string;
6
+ emittedAt: string;
7
+ attempts: number;
8
+ requestId?: string;
9
+ }
10
+ interface IEventRecord {
11
+ id: string;
12
+ type: string;
13
+ payload: Record<string, unknown>;
14
+ meta: IEventMeta;
15
+ created_at: Date;
16
+ }
17
+ interface IConsumerRecord {
18
+ event_id: string;
19
+ consumer: string;
20
+ status: 'pending' | 'processing' | 'processed' | 'failed' | 'dead';
21
+ attempts: number;
22
+ error: string | null;
23
+ processed_at: Date | null;
24
+ }
25
+ type IEventHandler<T = unknown> = (payload: T, meta: IEventMeta) => Promise<void>;
26
+
27
+ interface IEventTransport {
28
+ publish(type: string, payload: unknown, meta: IEventMeta): Promise<void>;
29
+ subscribe(type: string, handler: IEventHandler, consumer: string): void;
30
+ start(): Promise<void>;
31
+ stop(): Promise<void>;
32
+ }
33
+
34
+ declare class EventBus {
35
+ private transport;
36
+ constructor(transport: IEventTransport);
37
+ emit<T = unknown>(type: string, payload: T, opts?: {
38
+ requestId?: string;
39
+ }): Promise<void>;
40
+ on<T = unknown>(type: string, handler: IEventHandler<T>, consumer?: string): void;
41
+ start(): Promise<void>;
42
+ stop(): Promise<void>;
43
+ }
44
+
45
+ type EventTransportConfig = {
46
+ type: 'pg';
47
+ connectionUrl: string;
48
+ maxRetries?: number;
49
+ batchSize?: number;
50
+ pollInterval?: number;
51
+ } | IEventTransport;
52
+ interface IEventsConfig {
53
+ transport: EventTransportConfig;
54
+ }
55
+ declare class EventsModule implements IFonderieModule {
56
+ readonly name = "@fonderie/events";
57
+ readonly bus: EventBus;
58
+ constructor(config: IEventsConfig);
59
+ install(_app: IFonderieApp): void;
60
+ }
61
+
62
+ declare class MemoryTransport implements IEventTransport {
63
+ private subscriptions;
64
+ publish(type: string, payload: unknown, meta: IEventMeta): Promise<void>;
65
+ subscribe(pattern: string, handler: IEventHandler, _consumer: string): void;
66
+ start(): Promise<void>;
67
+ stop(): Promise<void>;
68
+ }
69
+
70
+ interface IPGTransportConfig {
71
+ connectionUrl: string;
72
+ maxRetries?: number;
73
+ batchSize?: number;
74
+ pollInterval?: number;
75
+ }
76
+ declare class PGTransport implements IEventTransport {
77
+ private config;
78
+ private subscriptions;
79
+ private listenClient;
80
+ private store;
81
+ private running;
82
+ private wakeResolvers;
83
+ private readonly maxRetries;
84
+ private readonly batchSize;
85
+ private readonly pollInterval;
86
+ constructor(config: IPGTransportConfig);
87
+ subscribe(pattern: string, handler: IEventHandler, consumer: string): void;
88
+ publish(type: string, payload: unknown, meta: IEventMeta): Promise<void>;
89
+ start(): Promise<void>;
90
+ stop(): Promise<void>;
91
+ private runPollLoop;
92
+ private pollAllConsumers;
93
+ private pollConsumer;
94
+ private processConsumerEvent;
95
+ private matchingConsumers;
96
+ private sleep;
97
+ private wake;
98
+ }
99
+
100
+ declare function matchesPattern(pattern: string, eventType: string): boolean;
101
+
102
+ declare const NOTIFICATION_EVENT: "fonderie.notification.send";
103
+ type NotificationEvent = typeof NOTIFICATION_EVENT;
104
+
105
+ export { EventBus, type EventTransportConfig, EventsModule, type IConsumerRecord, type IEventHandler, type IEventMeta, type IEventRecord, type IEventTransport, type IEventsConfig, type IPGTransportConfig, MemoryTransport, NOTIFICATION_EVENT, type NotificationEvent, PGTransport, matchesPattern };
package/dist/index.js ADDED
@@ -0,0 +1,244 @@
1
+ // src/bus.ts
2
+ import { randomUUID } from "crypto";
3
+ var EventBus = class {
4
+ constructor(transport) {
5
+ this.transport = transport;
6
+ }
7
+ transport;
8
+ async emit(type, payload, opts) {
9
+ const meta = {
10
+ id: randomUUID(),
11
+ type,
12
+ emittedAt: (/* @__PURE__ */ new Date()).toISOString(),
13
+ attempts: 0,
14
+ ...opts?.requestId !== void 0 ? { requestId: opts.requestId } : {}
15
+ };
16
+ await this.transport.publish(type, payload, meta);
17
+ }
18
+ // consumer identifies the logical subscriber for per-consumer delivery tracking.
19
+ // Defaults to the pattern string — stable and predictable for single-subscriber patterns.
20
+ on(type, handler, consumer = type) {
21
+ this.transport.subscribe(type, handler, consumer);
22
+ }
23
+ async start() {
24
+ await this.transport.start();
25
+ }
26
+ async stop() {
27
+ await this.transport.stop();
28
+ }
29
+ };
30
+
31
+ // src/transports/pg.ts
32
+ import pg from "pg";
33
+ import { PGAdapter } from "@fonderie/store";
34
+
35
+ // src/transports/pattern.ts
36
+ function matchesPattern(pattern, eventType) {
37
+ if (pattern === "*") return true;
38
+ const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
39
+ return regex.test(eventType);
40
+ }
41
+
42
+ // src/transports/pg.ts
43
+ var PGTransport = class {
44
+ constructor(config) {
45
+ this.config = config;
46
+ this.maxRetries = config.maxRetries ?? 3;
47
+ this.batchSize = config.batchSize ?? 10;
48
+ this.pollInterval = config.pollInterval ?? 1e3;
49
+ }
50
+ config;
51
+ subscriptions = [];
52
+ listenClient = null;
53
+ store;
54
+ running = false;
55
+ wakeResolvers = [];
56
+ maxRetries;
57
+ batchSize;
58
+ pollInterval;
59
+ // ── Public API ──────────────────────────────────────────────────
60
+ subscribe(pattern, handler, consumer) {
61
+ this.subscriptions.push({ pattern, handler, consumer });
62
+ }
63
+ async publish(type, payload, meta) {
64
+ await this.store.query(
65
+ `INSERT INTO fonderie_events (id, type, payload, meta)
66
+ VALUES ($1, $2, $3, $4)`,
67
+ [meta.id, type, JSON.stringify(payload), JSON.stringify(meta)]
68
+ );
69
+ const consumers = this.matchingConsumers(type);
70
+ if (consumers.length > 0) {
71
+ await this.store.query(
72
+ `INSERT INTO fonderie_event_consumers (event_id, consumer, status, attempts)
73
+ SELECT $1, unnest($2::text[]), 'pending', 0
74
+ ON CONFLICT (event_id, consumer) DO NOTHING`,
75
+ [meta.id, consumers]
76
+ );
77
+ }
78
+ await this.store.query(`SELECT pg_notify('fonderie_events', '')`);
79
+ }
80
+ async start() {
81
+ this.running = true;
82
+ this.store = new PGAdapter(this.config.connectionUrl);
83
+ await this.store.query(
84
+ `UPDATE fonderie_event_consumers SET status = 'failed' WHERE status = 'processing'`
85
+ );
86
+ this.listenClient = new pg.Client(this.config.connectionUrl);
87
+ await this.listenClient.connect();
88
+ await this.listenClient.query("LISTEN fonderie_events");
89
+ this.listenClient.on("notification", () => this.wake());
90
+ this.listenClient.on(
91
+ "error",
92
+ (err) => console.error("[events:pg] listen client error:", err.message)
93
+ );
94
+ this.runPollLoop().catch((err) => console.error("[events:pg] poll loop crashed:", err));
95
+ }
96
+ async stop() {
97
+ this.running = false;
98
+ this.wake();
99
+ await this.listenClient?.end();
100
+ this.listenClient = null;
101
+ }
102
+ // ── Poll loop ───────────────────────────────────────────────────
103
+ async runPollLoop() {
104
+ while (this.running) {
105
+ try {
106
+ const hadWork = await this.pollAllConsumers();
107
+ if (!hadWork) await this.sleep();
108
+ } catch (err) {
109
+ console.error("[events:pg] poll error:", err);
110
+ await this.sleep();
111
+ }
112
+ }
113
+ }
114
+ async pollAllConsumers() {
115
+ const consumers = [...new Set(this.subscriptions.map((s) => s.consumer))];
116
+ const results = await Promise.all(consumers.map((c) => this.pollConsumer(c)));
117
+ return results.some((n) => n > 0);
118
+ }
119
+ async pollConsumer(consumer) {
120
+ const claimed = await this.store.query(
121
+ `UPDATE fonderie_event_consumers c
122
+ SET status = 'processing', attempts = c.attempts + 1
123
+ FROM (
124
+ SELECT event_id
125
+ FROM fonderie_event_consumers
126
+ WHERE consumer = $1
127
+ AND status IN ('pending', 'failed')
128
+ AND attempts < $2
129
+ ORDER BY event_id
130
+ LIMIT $3
131
+ FOR UPDATE SKIP LOCKED
132
+ ) AS locked
133
+ WHERE c.event_id = locked.event_id
134
+ AND c.consumer = $1
135
+ RETURNING c.event_id`,
136
+ [consumer, this.maxRetries, this.batchSize]
137
+ );
138
+ await Promise.all(claimed.map((row) => this.processConsumerEvent(consumer, row.event_id)));
139
+ return claimed.length;
140
+ }
141
+ // ── Event processing ────────────────────────────────────────────
142
+ async processConsumerEvent(consumer, eventId) {
143
+ const [event] = await this.store.query(
144
+ `SELECT type, payload, meta FROM fonderie_events WHERE id = $1`,
145
+ [eventId]
146
+ );
147
+ if (!event) return;
148
+ const handlers = this.subscriptions.filter((s) => s.consumer === consumer && matchesPattern(s.pattern, event.type)).map((s) => s.handler);
149
+ try {
150
+ await Promise.all(handlers.map((h) => h(event.payload, event.meta)));
151
+ await this.store.query(
152
+ `UPDATE fonderie_event_consumers
153
+ SET status = 'processed', processed_at = now()
154
+ WHERE event_id = $1 AND consumer = $2`,
155
+ [eventId, consumer]
156
+ );
157
+ } catch (err) {
158
+ await this.store.query(
159
+ `UPDATE fonderie_event_consumers
160
+ SET status = CASE WHEN attempts >= $1 THEN 'dead' ELSE 'failed' END,
161
+ error = $2
162
+ WHERE event_id = $3 AND consumer = $4`,
163
+ [this.maxRetries, err instanceof Error ? err.message : String(err), eventId, consumer]
164
+ );
165
+ }
166
+ }
167
+ // ── Helpers ─────────────────────────────────────────────────────
168
+ matchingConsumers(eventType) {
169
+ const seen = /* @__PURE__ */ new Set();
170
+ for (const sub of this.subscriptions) {
171
+ if (matchesPattern(sub.pattern, eventType)) seen.add(sub.consumer);
172
+ }
173
+ return [...seen];
174
+ }
175
+ sleep() {
176
+ return new Promise((resolve) => {
177
+ let timer;
178
+ const wake = () => {
179
+ clearTimeout(timer);
180
+ resolve();
181
+ };
182
+ timer = setTimeout(() => {
183
+ const idx = this.wakeResolvers.indexOf(wake);
184
+ if (idx !== -1) this.wakeResolvers.splice(idx, 1);
185
+ resolve();
186
+ }, this.pollInterval);
187
+ this.wakeResolvers.push(wake);
188
+ });
189
+ }
190
+ wake() {
191
+ this.wakeResolvers.shift()?.();
192
+ }
193
+ };
194
+
195
+ // src/module.ts
196
+ function resolveTransport(config) {
197
+ if ("type" in config && config.type === "pg") {
198
+ return new PGTransport({
199
+ connectionUrl: config.connectionUrl,
200
+ ...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {},
201
+ ...config.batchSize !== void 0 ? { batchSize: config.batchSize } : {},
202
+ ...config.pollInterval !== void 0 ? { pollInterval: config.pollInterval } : {}
203
+ });
204
+ }
205
+ return config;
206
+ }
207
+ var EventsModule = class {
208
+ name = "@fonderie/events";
209
+ bus;
210
+ constructor(config) {
211
+ this.bus = new EventBus(resolveTransport(config.transport));
212
+ }
213
+ install(_app) {
214
+ this.bus.start().catch((err) => console.error("[events] failed to start transport", err));
215
+ }
216
+ };
217
+
218
+ // src/transports/memory.ts
219
+ var MemoryTransport = class {
220
+ subscriptions = [];
221
+ async publish(type, payload, meta) {
222
+ const matching = this.subscriptions.filter((s) => matchesPattern(s.pattern, type));
223
+ await Promise.all(matching.map((s) => s.handler(payload, meta)));
224
+ }
225
+ subscribe(pattern, handler, _consumer) {
226
+ this.subscriptions.push({ pattern, handler });
227
+ }
228
+ async start() {
229
+ }
230
+ async stop() {
231
+ }
232
+ };
233
+
234
+ // src/index.ts
235
+ var NOTIFICATION_EVENT = "fonderie.notification.send";
236
+ export {
237
+ EventBus,
238
+ EventsModule,
239
+ MemoryTransport,
240
+ NOTIFICATION_EVENT,
241
+ PGTransport,
242
+ matchesPattern
243
+ };
244
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bus.ts","../src/transports/pg.ts","../src/transports/pattern.ts","../src/module.ts","../src/transports/memory.ts","../src/index.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport type { IEventTransport } from './transports/types';\nimport type { IEventMeta, IEventHandler } from './types';\n\nexport class EventBus {\n\tconstructor(private transport: IEventTransport) {}\n\n\tasync emit<T = unknown>(type: string, payload: T, opts?: { requestId?: string }): Promise<void> {\n\t\tconst meta: IEventMeta = {\n\t\t\tid: randomUUID(),\n\t\t\ttype,\n\t\t\temittedAt: new Date().toISOString(),\n\t\t\tattempts: 0,\n\t\t\t...(opts?.requestId !== undefined ? { requestId: opts.requestId } : {}),\n\t\t};\n\t\tawait this.transport.publish(type, payload, meta);\n\t}\n\n\t// consumer identifies the logical subscriber for per-consumer delivery tracking.\n\t// Defaults to the pattern string — stable and predictable for single-subscriber patterns.\n\ton<T = unknown>(type: string, handler: IEventHandler<T>, consumer: string = type): void {\n\t\tthis.transport.subscribe(type, handler as IEventHandler, consumer);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tawait this.transport.start();\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tawait this.transport.stop();\n\t}\n}\n","import pg from 'pg';\n\nimport { PGAdapter } from '@fonderie/store';\nimport type { IStoreAdapter } from '@fonderie/store';\nimport type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler, IEventRecord } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport interface IPGTransportConfig {\n\tconnectionUrl: string;\n\tmaxRetries?: number; // default 3\n\tbatchSize?: number; // default 10 rows claimed per consumer per poll cycle\n\tpollInterval?: number; // default 1000ms fallback poll when no NOTIFY arrives\n}\n\ninterface Subscription {\n\tpattern: string;\n\thandler: IEventHandler;\n\tconsumer: string;\n}\n\nexport class PGTransport implements IEventTransport {\n\tprivate subscriptions: Subscription[] = [];\n\tprivate listenClient: pg.Client | null = null;\n\tprivate store!: IStoreAdapter;\n\tprivate running = false;\n\tprivate wakeResolvers: Array<() => void> = [];\n\n\tprivate readonly maxRetries: number;\n\tprivate readonly batchSize: number;\n\tprivate readonly pollInterval: number;\n\n\tconstructor(private config: IPGTransportConfig) {\n\t\tthis.maxRetries = config.maxRetries ?? 3;\n\t\tthis.batchSize = config.batchSize ?? 10;\n\t\tthis.pollInterval = config.pollInterval ?? 1_000;\n\t}\n\n\t// ── Public API ──────────────────────────────────────────────────\n\n\tsubscribe(pattern: string, handler: IEventHandler, consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler, consumer });\n\t}\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_events (id, type, payload, meta)\n\t\t\t VALUES ($1, $2, $3, $4)`,\n\t\t\t[meta.id, type, JSON.stringify(payload), JSON.stringify(meta)],\n\t\t);\n\n\t\tconst consumers = this.matchingConsumers(type);\n\t\tif (consumers.length > 0) {\n\t\t\tawait this.store.query(\n\t\t\t\t`INSERT INTO fonderie_event_consumers (event_id, consumer, status, attempts)\n\t\t\t\t SELECT $1, unnest($2::text[]), 'pending', 0\n\t\t\t\t ON CONFLICT (event_id, consumer) DO NOTHING`,\n\t\t\t\t[meta.id, consumers],\n\t\t\t);\n\t\t}\n\n\t\t// NOTIFY carries no payload — it is a wake signal only\n\t\tawait this.store.query(`SELECT pg_notify('fonderie_events', '')`);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tthis.running = true;\n\t\tthis.store = new PGAdapter(this.config.connectionUrl);\n\n\t\t// Reset any rows left in 'processing' by a crashed instance\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_event_consumers SET status = 'failed' WHERE status = 'processing'`,\n\t\t);\n\n\t\tthis.listenClient = new pg.Client(this.config.connectionUrl);\n\t\tawait this.listenClient.connect();\n\t\tawait this.listenClient.query('LISTEN fonderie_events');\n\n\t\tthis.listenClient.on('notification', () => this.wake());\n\t\tthis.listenClient.on('error', (err) =>\n\t\t\tconsole.error('[events:pg] listen client error:', err.message),\n\t\t);\n\n\t\tthis.runPollLoop().catch((err) => console.error('[events:pg] poll loop crashed:', err));\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tthis.running = false;\n\t\tthis.wake();\n\t\tawait this.listenClient?.end();\n\t\tthis.listenClient = null;\n\t}\n\n\t// ── Poll loop ───────────────────────────────────────────────────\n\n\tprivate async runPollLoop(): Promise<void> {\n\t\twhile (this.running) {\n\t\t\ttry {\n\t\t\t\tconst hadWork = await this.pollAllConsumers();\n\t\t\t\tif (!hadWork) await this.sleep();\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[events:pg] poll error:', err);\n\t\t\t\tawait this.sleep();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async pollAllConsumers(): Promise<boolean> {\n\t\tconst consumers = [...new Set(this.subscriptions.map((s) => s.consumer))];\n\t\tconst results = await Promise.all(consumers.map((c) => this.pollConsumer(c)));\n\t\treturn results.some((n) => n > 0);\n\t}\n\n\tprivate async pollConsumer(consumer: string): Promise<number> {\n\t\tconst claimed = await this.store.query<{ event_id: string }>(\n\t\t\t`UPDATE fonderie_event_consumers c\n\t\t\t SET status = 'processing', attempts = c.attempts + 1\n\t\t\t FROM (\n\t\t\t SELECT event_id\n\t\t\t FROM fonderie_event_consumers\n\t\t\t WHERE consumer = $1\n\t\t\t AND status IN ('pending', 'failed')\n\t\t\t AND attempts < $2\n\t\t\t ORDER BY event_id\n\t\t\t LIMIT $3\n\t\t\t FOR UPDATE SKIP LOCKED\n\t\t\t ) AS locked\n\t\t\t WHERE c.event_id = locked.event_id\n\t\t\t AND c.consumer = $1\n\t\t\t RETURNING c.event_id`,\n\t\t\t[consumer, this.maxRetries, this.batchSize],\n\t\t);\n\n\t\tawait Promise.all(claimed.map((row) => this.processConsumerEvent(consumer, row.event_id)));\n\t\treturn claimed.length;\n\t}\n\n\t// ── Event processing ────────────────────────────────────────────\n\n\tprivate async processConsumerEvent(consumer: string, eventId: string): Promise<void> {\n\t\tconst [event] = await this.store.query<IEventRecord>(\n\t\t\t`SELECT type, payload, meta FROM fonderie_events WHERE id = $1`,\n\t\t\t[eventId],\n\t\t);\n\t\tif (!event) return;\n\n\t\tconst handlers = this.subscriptions\n\t\t\t.filter((s) => s.consumer === consumer && matchesPattern(s.pattern, event.type))\n\t\t\t.map((s) => s.handler);\n\n\t\ttry {\n\t\t\tawait Promise.all(handlers.map((h) => h(event.payload, event.meta)));\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = 'processed', processed_at = now()\n\t\t\t\t WHERE event_id = $1 AND consumer = $2`,\n\t\t\t\t[eventId, consumer],\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = CASE WHEN attempts >= $1 THEN 'dead' ELSE 'failed' END,\n\t\t\t\t error = $2\n\t\t\t\t WHERE event_id = $3 AND consumer = $4`,\n\t\t\t\t[this.maxRetries, err instanceof Error ? err.message : String(err), eventId, consumer],\n\t\t\t);\n\t\t}\n\t}\n\n\t// ── Helpers ─────────────────────────────────────────────────────\n\n\tprivate matchingConsumers(eventType: string): string[] {\n\t\tconst seen = new Set<string>();\n\t\tfor (const sub of this.subscriptions) {\n\t\t\tif (matchesPattern(sub.pattern, eventType)) seen.add(sub.consumer);\n\t\t}\n\t\treturn [...seen];\n\t}\n\n\tprivate sleep(): Promise<void> {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tlet timer: ReturnType<typeof setTimeout>;\n\t\t\tconst wake = () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\ttimer = setTimeout(() => {\n\t\t\t\tconst idx = this.wakeResolvers.indexOf(wake);\n\t\t\t\tif (idx !== -1) this.wakeResolvers.splice(idx, 1);\n\t\t\t\tresolve();\n\t\t\t}, this.pollInterval);\n\t\t\tthis.wakeResolvers.push(wake);\n\t\t});\n\t}\n\n\tprivate wake(): void {\n\t\tthis.wakeResolvers.shift()?.();\n\t}\n}\n","// Glob matching for event topic patterns.\n// '*' alone matches everything. Otherwise '*' is a wildcard for any\n// characters including dots, so 'sport.*' matches 'sport.event.created'.\nexport function matchesPattern(pattern: string, eventType: string): boolean {\n\tif (pattern === '*') return true;\n\tconst regex = new RegExp('^' + pattern.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*') + '$');\n\treturn regex.test(eventType);\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\n\nimport { EventBus } from './bus';\nimport { PGTransport } from './transports/pg';\nimport type { IEventTransport } from './transports/types';\n\nexport type EventTransportConfig =\n\t| {\n\t\t\ttype: 'pg';\n\t\t\tconnectionUrl: string;\n\t\t\tmaxRetries?: number;\n\t\t\tbatchSize?: number;\n\t\t\tpollInterval?: number;\n\t }\n\t| IEventTransport;\n\nexport interface IEventsConfig {\n\ttransport: EventTransportConfig;\n}\n\nfunction resolveTransport(config: EventTransportConfig): IEventTransport {\n\tif ('type' in config && config.type === 'pg') {\n\t\treturn new PGTransport({\n\t\t\tconnectionUrl: config.connectionUrl,\n\t\t\t...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),\n\t\t\t...(config.batchSize !== undefined ? { batchSize: config.batchSize } : {}),\n\t\t\t...(config.pollInterval !== undefined ? { pollInterval: config.pollInterval } : {}),\n\t\t});\n\t}\n\n\treturn config as IEventTransport;\n}\n\nexport class EventsModule implements IFonderieModule {\n\treadonly name = '@fonderie/events';\n\treadonly bus: EventBus;\n\n\tconstructor(config: IEventsConfig) {\n\t\tthis.bus = new EventBus(resolveTransport(config.transport));\n\t}\n\n\tinstall(_app: IFonderieApp): void {\n\t\tthis.bus.start().catch((err) => console.error('[events] failed to start transport', err));\n\t}\n}\n","import type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport class MemoryTransport implements IEventTransport {\n\tprivate subscriptions: Array<{ pattern: string; handler: IEventHandler }> = [];\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tconst matching = this.subscriptions.filter((s) => matchesPattern(s.pattern, type));\n\t\tawait Promise.all(matching.map((s) => s.handler(payload, meta)));\n\t}\n\n\tsubscribe(pattern: string, handler: IEventHandler, _consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler });\n\t}\n\n\tasync start(): Promise<void> {}\n\tasync stop(): Promise<void> {}\n}\n","export { EventBus } from './bus';\nexport { EventsModule } from './module';\nexport type { IEventsConfig, EventTransportConfig } from './module';\n\nexport { MemoryTransport, PGTransport } from './transports';\nexport type { IEventTransport, IPGTransportConfig } from './transports';\n\nexport { matchesPattern } from './transports/pattern';\n\nexport type { IEventMeta, IEventHandler, IEventRecord, IConsumerRecord } from './types';\n\n// ── Typed event keys ─────────────────────────────────────────────\n// Each domain package re-exports its own EVENT_KEYS.\n// Consumers alias on import:\n// import { EVENT_KEYS as AUTH_EVENT_KEYS } from '@fonderie/auth'\n\nexport const NOTIFICATION_EVENT = 'fonderie.notification.send' as const;\nexport type NotificationEvent = typeof NOTIFICATION_EVENT;\n"],"mappings":"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","names":[]}
@@ -0,0 +1,3 @@
1
+ declare function getMigrationsPath(): string;
2
+
3
+ export { getMigrationsPath };
@@ -0,0 +1,9 @@
1
+ // src/migrations/index.ts
2
+ import { createMigrationsPath } from "@fonderie/store";
3
+ function getMigrationsPath() {
4
+ return createMigrationsPath(import.meta.url);
5
+ }
6
+ export {
7
+ getMigrationsPath
8
+ };
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/migrations/index.ts"],"sourcesContent":["import { createMigrationsPath } from '@fonderie/store';\n\nexport function getMigrationsPath(): string {\n\treturn createMigrationsPath(import.meta.url);\n}\n"],"mappings":";AAAA,SAAS,4BAA4B;AAE9B,SAAS,oBAA4B;AAC3C,SAAO,qBAAqB,YAAY,GAAG;AAC5C;","names":[]}
@@ -0,0 +1,10 @@
1
+ CREATE TABLE IF NOT EXISTS fonderie_events (
2
+ id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
3
+ type TEXT NOT NULL,
4
+ payload JSONB NOT NULL DEFAULT '{}',
5
+ meta JSONB NOT NULL DEFAULT '{}',
6
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
7
+ );
8
+
9
+ CREATE INDEX IF NOT EXISTS fonderie_events_created_idx
10
+ ON fonderie_events (created_at);
@@ -0,0 +1,25 @@
1
+ -- Drop mutable delivery columns from fonderie_events (no-op on fresh installs)
2
+ ALTER TABLE fonderie_events
3
+ DROP COLUMN IF EXISTS status,
4
+ DROP COLUMN IF EXISTS attempts,
5
+ DROP COLUMN IF EXISTS error,
6
+ DROP COLUMN IF EXISTS processed_at;
7
+
8
+ DROP INDEX IF EXISTS fonderie_events_status_idx;
9
+
10
+ -- Per-consumer delivery tracking
11
+ CREATE TABLE IF NOT EXISTS fonderie_event_consumers (
12
+ event_id UUID NOT NULL REFERENCES fonderie_events(id) ON DELETE CASCADE,
13
+ consumer TEXT NOT NULL,
14
+ status TEXT NOT NULL DEFAULT 'pending'
15
+ CHECK (status IN ('pending', 'processing', 'processed', 'failed', 'dead')),
16
+ attempts INT NOT NULL DEFAULT 0,
17
+ error TEXT,
18
+ processed_at TIMESTAMPTZ,
19
+ PRIMARY KEY (event_id, consumer)
20
+ );
21
+
22
+ -- Partial index — each consumer's poll touches only its own pending rows
23
+ CREATE INDEX IF NOT EXISTS fonderie_event_consumers_poll_idx
24
+ ON fonderie_event_consumers (consumer, status, event_id)
25
+ WHERE status IN ('pending', 'failed');
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@fonderie/events",
3
+ "version": "1.0.0",
4
+ "description": "Event bus for @fonderie-js — memory and PostgreSQL transports built-in, adapter interface for Redis/Kafka/RabbitMQ.",
5
+ "keywords": [
6
+ "fonderie-js",
7
+ "events",
8
+ "event-bus",
9
+ "pubsub",
10
+ "outbox",
11
+ "typescript"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "require": "./dist/index.cjs"
23
+ },
24
+ "./migrations": {
25
+ "types": "./dist/migrations/index.d.ts",
26
+ "import": "./dist/migrations/index.js"
27
+ }
28
+ },
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "scripts": {
33
+ "build": "tsup && node -e \"require('node:fs').cpSync('src/migrations/sql', 'dist/migrations/sql', {recursive:true})\"",
34
+ "dev": "tsup --watch",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "tsx --test src/__tests__/*.test.ts",
37
+ "lint": "biome lint src",
38
+ "format": "biome format --write src",
39
+ "check": "biome check --write src"
40
+ },
41
+ "peerDependencies": {
42
+ "@fonderie/core": "^0.1.0",
43
+ "@fonderie/store": "^0.1.0",
44
+ "pg": "^8.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@fonderie/store": {
48
+ "optional": true
49
+ },
50
+ "pg": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@fonderie/core": "../core",
56
+ "@fonderie/store": "../store",
57
+ "@types/node": "^25.6.0",
58
+ "@types/pg": "^8.11.10",
59
+ "pg": "^8.13.3",
60
+ "tsup": "^8.5.1",
61
+ "tsx": "^4.21.0",
62
+ "typescript": "^6.0.3"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ },
67
+ "files": [
68
+ "dist",
69
+ "LICENSE",
70
+ "README.md"
71
+ ],
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "git+https://github.com/fonderie-js/sdk.git",
75
+ "directory": "packages/events"
76
+ },
77
+ "homepage": "https://github.com/fonderie-js/sdk/tree/main/packages/events#readme",
78
+ "bugs": {
79
+ "url": "https://github.com/fonderie-js/sdk/issues"
80
+ }
81
+ }