abxbus 2.4.16 → 2.4.18

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.
Files changed (42) hide show
  1. package/dist/cjs/event_bus.js +1 -1
  2. package/dist/cjs/event_bus.js.map +2 -2
  3. package/dist/cjs/event_handler.d.ts +1 -0
  4. package/dist/cjs/event_handler.js +14 -1
  5. package/dist/cjs/event_handler.js.map +2 -2
  6. package/dist/cjs/middleware_otel_tracing.js +3 -3
  7. package/dist/cjs/middleware_otel_tracing.js.map +2 -2
  8. package/dist/cjs/retry.js +39 -0
  9. package/dist/cjs/retry.js.map +2 -2
  10. package/dist/esm/event_bus.js +1 -1
  11. package/dist/esm/event_bus.js.map +2 -2
  12. package/dist/esm/event_handler.js +14 -1
  13. package/dist/esm/event_handler.js.map +2 -2
  14. package/dist/esm/middleware_otel_tracing.js +4 -4
  15. package/dist/esm/middleware_otel_tracing.js.map +2 -2
  16. package/dist/esm/retry.js +39 -0
  17. package/dist/esm/retry.js.map +2 -2
  18. package/dist/types/event_handler.d.ts +1 -0
  19. package/package.json +5 -1
  20. package/src/async_context.ts +70 -0
  21. package/src/base_event.ts +1201 -0
  22. package/src/bridge_jsonl.ts +174 -0
  23. package/src/bridge_nats.ts +104 -0
  24. package/src/bridge_postgres.ts +277 -0
  25. package/src/bridge_redis.ts +194 -0
  26. package/src/bridge_sqlite.ts +289 -0
  27. package/src/bridges.ts +376 -0
  28. package/src/event_bus.ts +1263 -0
  29. package/src/event_handler.ts +379 -0
  30. package/src/event_history.ts +247 -0
  31. package/src/event_result.ts +483 -0
  32. package/src/events_suck.ts +96 -0
  33. package/src/helpers.ts +65 -0
  34. package/src/index.ts +37 -0
  35. package/src/lock_manager.ts +401 -0
  36. package/src/logging.ts +261 -0
  37. package/src/middleware_otel_tracing.ts +201 -0
  38. package/src/middlewares.ts +16 -0
  39. package/src/optional_deps.ts +52 -0
  40. package/src/retry.ts +578 -0
  41. package/src/timing.ts +52 -0
  42. package/src/types.ts +132 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Redis pub/sub bridge for forwarding events between runtimes.
3
+ *
4
+ * Usage:
5
+ * // channel from URL path
6
+ * const bridge = new RedisEventBridge('redis://user:pass@localhost:6379/1/my_channel')
7
+ *
8
+ * // explicit channel override
9
+ * const bridge2 = new RedisEventBridge('redis://user:pass@localhost:6379/1', 'my_channel')
10
+ *
11
+ * URL format:
12
+ * redis://user:pass@host:6379/<db>/<optional_channel>
13
+ */
14
+ import { BaseEvent } from './base_event.js'
15
+ import { EventBus } from './event_bus.js'
16
+ import { assertOptionalDependencyAvailable, importOptionalDependency, isNodeRuntime } from './optional_deps.js'
17
+ import type { EventClass, EventHandlerCallable, EventPattern, UntypedEventHandlerFunction } from './types.js'
18
+
19
+ const randomSuffix = (): string => Math.random().toString(36).slice(2, 10)
20
+ const DEFAULT_REDIS_CHANNEL = 'abxbus_events'
21
+ const DB_INIT_KEY = '__abxbus:bridge_init__'
22
+
23
+ const parseRedisUrl = (redis_url: string, channel?: string): { url: string; channel: string } => {
24
+ let parsed: URL
25
+ try {
26
+ parsed = new URL(redis_url)
27
+ } catch {
28
+ throw new Error(`RedisEventBridge URL must be a valid redis:// or rediss:// URL, got: ${redis_url}`)
29
+ }
30
+
31
+ const protocol = parsed.protocol.replace(/:$/, '').toLowerCase()
32
+ if (protocol !== 'redis' && protocol !== 'rediss') {
33
+ throw new Error(`RedisEventBridge URL must use redis:// or rediss://, got: ${redis_url}`)
34
+ }
35
+
36
+ const segments = parsed.pathname.split('/').filter(Boolean)
37
+ if (segments.length > 2) {
38
+ throw new Error(`RedisEventBridge URL path must be /<db> or /<db>/<channel>, got: ${parsed.pathname || '/'}`)
39
+ }
40
+
41
+ let db_index = '0'
42
+ let channel_from_url: string | undefined
43
+
44
+ if (segments.length > 0) {
45
+ db_index = segments[0]
46
+ if (!/^\d+$/.test(db_index)) {
47
+ throw new Error(`RedisEventBridge URL db path segment must be numeric, got: ${JSON.stringify(db_index)} in ${redis_url}`)
48
+ }
49
+ if (segments.length === 2) {
50
+ channel_from_url = segments[1]
51
+ }
52
+ }
53
+
54
+ const resolved_channel = channel ?? channel_from_url ?? DEFAULT_REDIS_CHANNEL
55
+ if (!resolved_channel) {
56
+ throw new Error('RedisEventBridge channel must not be empty')
57
+ }
58
+
59
+ const normalized = new URL(parsed.toString())
60
+ normalized.pathname = `/${db_index}`
61
+ return { url: normalized.toString(), channel: resolved_channel }
62
+ }
63
+
64
+ export class RedisEventBridge {
65
+ readonly url: string
66
+ readonly channel: string
67
+ readonly name: string
68
+
69
+ private readonly inbound_bus: EventBus
70
+ private running: boolean
71
+ private start_promise: Promise<void> | null
72
+ private redis_pub: any | null
73
+ private redis_sub: any | null
74
+
75
+ constructor(redis_url: string, channel?: string, name?: string) {
76
+ assertOptionalDependencyAvailable('RedisEventBridge', 'ioredis')
77
+
78
+ const parsed = parseRedisUrl(redis_url, channel)
79
+ this.url = parsed.url
80
+ this.channel = parsed.channel
81
+ this.name = name ?? `RedisEventBridge_${randomSuffix()}`
82
+ this.inbound_bus = new EventBus(this.name, { max_history_size: 0 })
83
+ this.running = false
84
+ this.start_promise = null
85
+ this.redis_pub = null
86
+ this.redis_sub = null
87
+
88
+ this.dispatch = this.dispatch.bind(this)
89
+ this.emit = this.emit.bind(this)
90
+ this.on = this.on.bind(this)
91
+ }
92
+
93
+ on<T extends BaseEvent>(event_pattern: EventClass<T>, handler: EventHandlerCallable<T>): void
94
+ on<T extends BaseEvent>(event_pattern: string | '*', handler: UntypedEventHandlerFunction<T>): void
95
+ on(event_pattern: EventPattern | '*', handler: EventHandlerCallable | UntypedEventHandlerFunction): void {
96
+ this.ensureStarted()
97
+ if (typeof event_pattern === 'string') {
98
+ this.inbound_bus.on(event_pattern, handler as UntypedEventHandlerFunction<BaseEvent>)
99
+ return
100
+ }
101
+ this.inbound_bus.on(event_pattern as EventClass<BaseEvent>, handler as EventHandlerCallable<BaseEvent>)
102
+ }
103
+
104
+ async emit<T extends BaseEvent>(event: T): Promise<void> {
105
+ this.ensureStarted()
106
+ if (!this.redis_pub) await this.start()
107
+ const payload = JSON.stringify(event.toJSON())
108
+ await this.redis_pub.publish(this.channel, payload)
109
+ }
110
+
111
+ async dispatch<T extends BaseEvent>(event: T): Promise<void> {
112
+ return this.emit(event)
113
+ }
114
+
115
+ async start(): Promise<void> {
116
+ if (this.running) return
117
+ if (this.start_promise) {
118
+ await this.start_promise
119
+ return
120
+ }
121
+
122
+ // `on(...)` auto-start and explicit `await start()` can happen back-to-back; use one in-flight
123
+ // startup promise so we do not leak extra Redis clients.
124
+ this.start_promise = (async () => {
125
+ if (!isNodeRuntime()) {
126
+ throw new Error('RedisEventBridge is only supported in Node.js runtimes')
127
+ }
128
+
129
+ const mod = await importOptionalDependency('RedisEventBridge', 'ioredis')
130
+ const Redis = mod.default ?? mod.Redis ?? mod
131
+ const redis_pub = new Redis(this.url)
132
+ const redis_sub = new Redis(this.url)
133
+
134
+ redis_pub.on('error', () => {})
135
+ redis_sub.on('error', () => {})
136
+
137
+ // Redis logical DBs are created lazily; writing a short-lived key initializes/validates the selected DB.
138
+ await redis_pub.set(DB_INIT_KEY, '1', 'EX', 60, 'NX')
139
+ redis_sub.on('message', (channel_name: string, message: string) => {
140
+ if (channel_name !== this.channel) return
141
+ try {
142
+ const payload = JSON.parse(message)
143
+ void this.dispatchInboundPayload(payload)
144
+ } catch {
145
+ // Ignore malformed payloads.
146
+ }
147
+ })
148
+ await redis_sub.subscribe(this.channel)
149
+ this.redis_pub = redis_pub
150
+ this.redis_sub = redis_sub
151
+ this.running = true
152
+ })()
153
+
154
+ try {
155
+ await this.start_promise
156
+ } finally {
157
+ this.start_promise = null
158
+ }
159
+ }
160
+
161
+ async close(): Promise<void> {
162
+ if (this.start_promise) {
163
+ await this.start_promise.catch(() => {})
164
+ }
165
+ this.running = false
166
+ if (this.redis_sub) {
167
+ try {
168
+ await this.redis_sub.unsubscribe(this.channel)
169
+ } catch {
170
+ // ignore
171
+ }
172
+ await this.redis_sub.quit()
173
+ this.redis_sub = null
174
+ }
175
+ if (this.redis_pub) {
176
+ await this.redis_pub.quit()
177
+ this.redis_pub = null
178
+ }
179
+ this.inbound_bus.destroy()
180
+ }
181
+
182
+ private ensureStarted(): void {
183
+ if (this.running) return
184
+ if (this.start_promise) return
185
+ void this.start().catch((error: unknown) => {
186
+ console.error('[abxbus] RedisEventBridge failed to start', error)
187
+ })
188
+ }
189
+
190
+ private async dispatchInboundPayload(payload: unknown): Promise<void> {
191
+ const event = BaseEvent.fromJSON(payload).eventReset()
192
+ this.inbound_bus.emit(event)
193
+ }
194
+ }
@@ -0,0 +1,289 @@
1
+ import { BaseEvent } from './base_event.js'
2
+ import { EventBus } from './event_bus.js'
3
+ import { isNodeRuntime } from './optional_deps.js'
4
+ import type { EventClass, EventHandlerCallable, EventPattern, UntypedEventHandlerFunction } from './types.js'
5
+
6
+ const randomSuffix = (): string => Math.random().toString(36).slice(2, 10)
7
+ const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
8
+ const EVENT_PAYLOAD_COLUMN = 'event_payload'
9
+
10
+ const validateIdentifier = (value: string, label: string): string => {
11
+ if (!IDENTIFIER_RE.test(value)) {
12
+ throw new Error(`Invalid ${label}: ${JSON.stringify(value)}. Use only [A-Za-z0-9_] and start with a letter/_`)
13
+ }
14
+ return value
15
+ }
16
+
17
+ const loadNodeSqlite = async (): Promise<any> => {
18
+ const dynamic_import = Function('module_name', 'return import(module_name)') as (module_name: string) => Promise<unknown>
19
+ try {
20
+ return (await dynamic_import('node:sqlite')) as any
21
+ } catch {
22
+ throw new Error('SQLiteEventBridge requires Node.js with built-in "node:sqlite" support (Node 22+).')
23
+ }
24
+ }
25
+
26
+ const splitBridgePayload = (
27
+ payload: Record<string, unknown>
28
+ ): { event_fields: Record<string, unknown>; event_payload: Record<string, unknown> } => {
29
+ const event_fields: Record<string, unknown> = {}
30
+ const event_payload: Record<string, unknown> = { ...payload }
31
+ for (const [key, value] of Object.entries(payload)) {
32
+ if (key.startsWith('event_')) {
33
+ event_fields[key] = value
34
+ }
35
+ }
36
+ return { event_fields, event_payload }
37
+ }
38
+
39
+ export class SQLiteEventBridge {
40
+ readonly path: string
41
+ readonly table: string
42
+ readonly poll_interval: number
43
+ readonly name: string
44
+
45
+ private readonly inbound_bus: EventBus
46
+ private running: boolean
47
+ private last_seen_event_created_at: string
48
+ private last_seen_event_id: string
49
+ private listener_task: Promise<void> | null
50
+ private start_task: Promise<void> | null
51
+ private db: any | null
52
+ private table_columns: Set<string>
53
+
54
+ constructor(path: string, table: string = 'abxbus_events', poll_interval: number = 0.25, name?: string) {
55
+ this.path = path
56
+ this.table = validateIdentifier(table, 'table name')
57
+ this.poll_interval = poll_interval
58
+ this.name = name ?? `SQLiteEventBridge_${randomSuffix()}`
59
+ this.inbound_bus = new EventBus(this.name, { max_history_size: 0 })
60
+ this.running = false
61
+ this.last_seen_event_created_at = ''
62
+ this.last_seen_event_id = ''
63
+ this.listener_task = null
64
+ this.start_task = null
65
+ this.db = null
66
+ this.table_columns = new Set(['event_id', 'event_created_at', 'event_type', EVENT_PAYLOAD_COLUMN])
67
+
68
+ this.dispatch = this.dispatch.bind(this)
69
+ this.emit = this.emit.bind(this)
70
+ this.on = this.on.bind(this)
71
+ }
72
+
73
+ on<T extends BaseEvent>(event_pattern: EventClass<T>, handler: EventHandlerCallable<T>): void
74
+ on<T extends BaseEvent>(event_pattern: string | '*', handler: UntypedEventHandlerFunction<T>): void
75
+ on(event_pattern: EventPattern | '*', handler: EventHandlerCallable | UntypedEventHandlerFunction): void {
76
+ this.ensureStarted()
77
+ if (typeof event_pattern === 'string') {
78
+ this.inbound_bus.on(event_pattern, handler as UntypedEventHandlerFunction<BaseEvent>)
79
+ return
80
+ }
81
+ this.inbound_bus.on(event_pattern as EventClass<BaseEvent>, handler as EventHandlerCallable<BaseEvent>)
82
+ }
83
+
84
+ async emit<T extends BaseEvent>(event: T): Promise<void> {
85
+ this.ensureStarted()
86
+ if (!this.running) {
87
+ await this.start()
88
+ }
89
+ if (!this.db) {
90
+ throw new Error('SQLiteEventBridge database not initialized')
91
+ }
92
+
93
+ const payload = event.toJSON() as Record<string, unknown>
94
+ const { event_fields, event_payload } = splitBridgePayload(payload)
95
+ const write_payload: Record<string, unknown> = { ...event_fields, [EVENT_PAYLOAD_COLUMN]: event_payload }
96
+ const payload_keys = Object.keys(write_payload).sort()
97
+ this.ensureColumns(payload_keys)
98
+
99
+ const columns_sql = payload_keys.map((key) => `"${key}"`).join(', ')
100
+ const placeholders_sql = payload_keys.map((key) => (key === EVENT_PAYLOAD_COLUMN ? 'json(?)' : '?')).join(', ')
101
+ const values = payload_keys.map((key) =>
102
+ write_payload[key] === null || write_payload[key] === undefined ? null : JSON.stringify(write_payload[key])
103
+ )
104
+
105
+ const update_fields = payload_keys.filter((key) => key !== 'event_id')
106
+ let upsert_sql = `INSERT INTO "${this.table}" (${columns_sql}) VALUES (${placeholders_sql})`
107
+ if (update_fields.length > 0) {
108
+ const updates_sql = update_fields.map((key) => `"${key}" = excluded."${key}"`).join(', ')
109
+ upsert_sql += ` ON CONFLICT("event_id") DO UPDATE SET ${updates_sql}`
110
+ } else {
111
+ upsert_sql += ' ON CONFLICT("event_id") DO NOTHING'
112
+ }
113
+
114
+ this.db.prepare(upsert_sql).run(...values)
115
+ }
116
+
117
+ async dispatch<T extends BaseEvent>(event: T): Promise<void> {
118
+ return this.emit(event)
119
+ }
120
+
121
+ async start(): Promise<void> {
122
+ if (this.running) return
123
+ if (this.start_task) {
124
+ await this.start_task
125
+ return
126
+ }
127
+
128
+ this.start_task = (async (): Promise<void> => {
129
+ if (!isNodeRuntime()) {
130
+ throw new Error('SQLiteEventBridge is only supported in Node.js runtimes')
131
+ }
132
+
133
+ const mod = await loadNodeSqlite()
134
+ const Database = mod.DatabaseSync ?? mod.default?.DatabaseSync
135
+ if (typeof Database !== 'function') {
136
+ throw new Error('SQLiteEventBridge could not load DatabaseSync from node:sqlite. Please use Node.js 22+.')
137
+ }
138
+ this.db = new Database(this.path)
139
+ this.db.exec('PRAGMA journal_mode = WAL')
140
+ this.db
141
+ .prepare(
142
+ `CREATE TABLE IF NOT EXISTS "${this.table}" ("event_id" TEXT PRIMARY KEY, "event_created_at" TEXT, "event_type" TEXT, "event_payload" JSON)`
143
+ )
144
+ .run()
145
+
146
+ this.refreshColumnCache()
147
+ this.ensureColumns(['event_id', 'event_created_at', 'event_type', EVENT_PAYLOAD_COLUMN])
148
+ this.ensureBaseIndexes()
149
+ this.setCursorToLatestRow()
150
+
151
+ this.running = true
152
+ this.listener_task = this.listenLoop()
153
+ })()
154
+
155
+ try {
156
+ await this.start_task
157
+ } finally {
158
+ this.start_task = null
159
+ }
160
+ }
161
+
162
+ async close(): Promise<void> {
163
+ await Promise.allSettled(this.start_task ? [this.start_task] : [])
164
+ this.running = false
165
+ await Promise.allSettled(this.listener_task ? [this.listener_task] : [])
166
+ this.listener_task = null
167
+
168
+ if (this.db) {
169
+ this.db.close()
170
+ this.db = null
171
+ }
172
+
173
+ this.inbound_bus.destroy()
174
+ }
175
+
176
+ private ensureStarted(): void {
177
+ if (this.running || this.listener_task || this.start_task) return
178
+ void this.start().catch((error: unknown) => {
179
+ console.error('[abxbus] SQLiteEventBridge failed to start', error)
180
+ })
181
+ }
182
+
183
+ private async listenLoop(): Promise<void> {
184
+ while (this.running) {
185
+ try {
186
+ if (this.db) {
187
+ const rows = this.db
188
+ .prepare(
189
+ `SELECT * FROM "${this.table}" WHERE COALESCE("event_created_at", '') > ? OR (COALESCE("event_created_at", '') = ? AND COALESCE("event_id", '') > ?) ORDER BY COALESCE("event_created_at", '') ASC, COALESCE("event_id", '') ASC`
190
+ )
191
+ .all(this.last_seen_event_created_at, this.last_seen_event_created_at, this.last_seen_event_id) as Array<
192
+ Record<string, unknown>
193
+ >
194
+
195
+ for (const row of rows) {
196
+ this.last_seen_event_created_at = String(row.event_created_at ?? '')
197
+ this.last_seen_event_id = String(row.event_id ?? '')
198
+
199
+ const raw_payload_blob = row[EVENT_PAYLOAD_COLUMN]
200
+ const payload: Record<string, unknown> = {}
201
+ if (typeof raw_payload_blob === 'string') {
202
+ try {
203
+ const decoded_event_payload = JSON.parse(raw_payload_blob)
204
+ if (decoded_event_payload && typeof decoded_event_payload === 'object' && !Array.isArray(decoded_event_payload)) {
205
+ Object.assign(payload, decoded_event_payload as Record<string, unknown>)
206
+ }
207
+ } catch {
208
+ // ignore malformed payload column
209
+ }
210
+ }
211
+
212
+ for (const [key, raw_value] of Object.entries(row)) {
213
+ if (key === EVENT_PAYLOAD_COLUMN || !key.startsWith('event_')) continue
214
+ if (raw_value === null || raw_value === undefined) continue
215
+
216
+ if (typeof raw_value !== 'string') {
217
+ payload[key] = raw_value
218
+ continue
219
+ }
220
+
221
+ try {
222
+ payload[key] = JSON.parse(raw_value)
223
+ } catch {
224
+ payload[key] = raw_value
225
+ }
226
+ }
227
+
228
+ await this.dispatchInboundPayload(payload)
229
+ }
230
+ }
231
+ } catch {
232
+ // Keep polling on transient errors.
233
+ }
234
+ await new Promise((resolve) => setTimeout(resolve, Math.max(1, this.poll_interval * 1000)))
235
+ }
236
+ }
237
+
238
+ private async dispatchInboundPayload(payload: unknown): Promise<void> {
239
+ const event = BaseEvent.fromJSON(payload).eventReset()
240
+ this.inbound_bus.emit(event)
241
+ }
242
+
243
+ private refreshColumnCache(): void {
244
+ if (!this.db) return
245
+ const rows = this.db.prepare(`PRAGMA table_info("${this.table}")`).all() as Array<{ name: string }>
246
+ this.table_columns = new Set(rows.map((row) => String(row.name)))
247
+ }
248
+
249
+ private ensureColumns(keys: string[]): void {
250
+ if (!this.db) return
251
+
252
+ for (const key of keys) {
253
+ validateIdentifier(key, 'event field name')
254
+ if (key !== EVENT_PAYLOAD_COLUMN && !key.startsWith('event_')) {
255
+ throw new Error(`Invalid event field name for bridge column: ${JSON.stringify(key)}. Only event_* fields become columns`)
256
+ }
257
+ }
258
+
259
+ const missing_columns = keys.filter((key) => !this.table_columns.has(key))
260
+ for (const key of missing_columns) {
261
+ const column_type = key === EVENT_PAYLOAD_COLUMN ? 'JSON' : 'TEXT'
262
+ this.db.prepare(`ALTER TABLE "${this.table}" ADD COLUMN "${key}" ${column_type}`).run()
263
+ this.table_columns.add(key)
264
+ }
265
+ }
266
+
267
+ private ensureBaseIndexes(): void {
268
+ if (!this.db) return
269
+
270
+ const event_created_at_index = `${this.table}_event_created_at_idx`
271
+ const event_type_index = `${this.table}_event_type_idx`
272
+
273
+ this.db.prepare(`CREATE INDEX IF NOT EXISTS "${event_created_at_index}" ON "${this.table}" ("event_created_at")`).run()
274
+ this.db.prepare(`CREATE INDEX IF NOT EXISTS "${event_type_index}" ON "${this.table}" ("event_type")`).run()
275
+ }
276
+
277
+ private setCursorToLatestRow(): void {
278
+ if (!this.db) return
279
+
280
+ const row = this.db
281
+ .prepare(
282
+ `SELECT COALESCE("event_created_at", '') AS event_created_at, COALESCE("event_id", '') AS event_id FROM "${this.table}" ORDER BY COALESCE("event_created_at", '') DESC, COALESCE("event_id", '') DESC LIMIT 1`
283
+ )
284
+ .get() as { event_created_at?: string; event_id?: string } | undefined
285
+
286
+ this.last_seen_event_created_at = String(row?.event_created_at ?? '')
287
+ this.last_seen_event_id = String(row?.event_id ?? '')
288
+ }
289
+ }