abxbus 2.4.15 → 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 (50) 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/index.d.ts +2 -0
  7. package/dist/cjs/index.js +2 -0
  8. package/dist/cjs/index.js.map +2 -2
  9. package/dist/cjs/middleware_otel_tracing.d.ts +28 -0
  10. package/dist/cjs/middleware_otel_tracing.js +189 -0
  11. package/dist/cjs/middleware_otel_tracing.js.map +7 -0
  12. package/dist/cjs/retry.js +39 -0
  13. package/dist/cjs/retry.js.map +2 -2
  14. package/dist/esm/event_bus.js +1 -1
  15. package/dist/esm/event_bus.js.map +2 -2
  16. package/dist/esm/event_handler.js +14 -1
  17. package/dist/esm/event_handler.js.map +2 -2
  18. package/dist/esm/index.js +2 -0
  19. package/dist/esm/index.js.map +2 -2
  20. package/dist/esm/middleware_otel_tracing.js +169 -0
  21. package/dist/esm/middleware_otel_tracing.js.map +7 -0
  22. package/dist/esm/retry.js +39 -0
  23. package/dist/esm/retry.js.map +2 -2
  24. package/dist/types/event_handler.d.ts +1 -0
  25. package/dist/types/index.d.ts +2 -0
  26. package/dist/types/middleware_otel_tracing.d.ts +28 -0
  27. package/package.json +6 -1
  28. package/src/async_context.ts +70 -0
  29. package/src/base_event.ts +1201 -0
  30. package/src/bridge_jsonl.ts +174 -0
  31. package/src/bridge_nats.ts +104 -0
  32. package/src/bridge_postgres.ts +277 -0
  33. package/src/bridge_redis.ts +194 -0
  34. package/src/bridge_sqlite.ts +289 -0
  35. package/src/bridges.ts +376 -0
  36. package/src/event_bus.ts +1263 -0
  37. package/src/event_handler.ts +379 -0
  38. package/src/event_history.ts +247 -0
  39. package/src/event_result.ts +483 -0
  40. package/src/events_suck.ts +96 -0
  41. package/src/helpers.ts +65 -0
  42. package/src/index.ts +37 -0
  43. package/src/lock_manager.ts +401 -0
  44. package/src/logging.ts +261 -0
  45. package/src/middleware_otel_tracing.ts +201 -0
  46. package/src/middlewares.ts +16 -0
  47. package/src/optional_deps.ts +52 -0
  48. package/src/retry.ts +578 -0
  49. package/src/timing.ts +52 -0
  50. package/src/types.ts +132 -0
@@ -0,0 +1,174 @@
1
+ import { BaseEvent } from './base_event.js'
2
+ import { EventBus } from './event_bus.js'
3
+ import type { EventClass, EventHandlerCallable, EventPattern, UntypedEventHandlerFunction } from './types.js'
4
+
5
+ const isNodeRuntime = (): boolean => {
6
+ const maybe_process = (globalThis as { process?: { versions?: { node?: string } } }).process
7
+ return typeof maybe_process?.versions?.node === 'string'
8
+ }
9
+
10
+ const importNodeModule = async (specifier: string): Promise<any> => {
11
+ const dynamic_import = Function('module_name', 'return import(module_name)') as (module_name: string) => Promise<unknown>
12
+ return dynamic_import(specifier) as Promise<any>
13
+ }
14
+
15
+ const randomSuffix = (): string => Math.random().toString(36).slice(2, 10)
16
+
17
+ export class JSONLEventBridge {
18
+ readonly path: string
19
+ readonly poll_interval: number
20
+ readonly name: string
21
+
22
+ private readonly inbound_bus: EventBus
23
+ private running: boolean
24
+ private byte_offset: number
25
+ private pending_line: string
26
+ private listener_task: Promise<void> | null
27
+
28
+ constructor(path: string, poll_interval: number = 0.25, name?: string) {
29
+ this.path = path
30
+ this.poll_interval = poll_interval
31
+ this.name = name ?? `JSONLEventBridge_${randomSuffix()}`
32
+ this.inbound_bus = new EventBus(this.name, { max_history_size: 0 })
33
+ this.running = false
34
+ this.byte_offset = 0
35
+ this.pending_line = ''
36
+ this.listener_task = null
37
+
38
+ this.dispatch = this.dispatch.bind(this)
39
+ this.emit = this.emit.bind(this)
40
+ this.on = this.on.bind(this)
41
+ }
42
+
43
+ on<T extends BaseEvent>(event_pattern: EventClass<T>, handler: EventHandlerCallable<T>): void
44
+ on<T extends BaseEvent>(event_pattern: string | '*', handler: UntypedEventHandlerFunction<T>): void
45
+ on(event_pattern: EventPattern | '*', handler: EventHandlerCallable | UntypedEventHandlerFunction): void {
46
+ this.ensureStarted()
47
+ if (typeof event_pattern === 'string') {
48
+ this.inbound_bus.on(event_pattern, handler as UntypedEventHandlerFunction<BaseEvent>)
49
+ return
50
+ }
51
+ this.inbound_bus.on(event_pattern as EventClass<BaseEvent>, handler as EventHandlerCallable<BaseEvent>)
52
+ }
53
+
54
+ async emit<T extends BaseEvent>(event: T): Promise<void> {
55
+ this.ensureStarted()
56
+ const fs = await this.loadFs()
57
+ await fs.promises.mkdir(this.dirname(this.path), { recursive: true })
58
+ const payload = JSON.stringify(event.toJSON()) + '\n'
59
+ await fs.promises.appendFile(this.path, payload, 'utf8')
60
+ }
61
+
62
+ async dispatch<T extends BaseEvent>(event: T): Promise<void> {
63
+ return this.emit(event)
64
+ }
65
+
66
+ async start(): Promise<void> {
67
+ if (this.running) return
68
+ const fs = await this.loadFs()
69
+ await fs.promises.mkdir(this.dirname(this.path), { recursive: true })
70
+ await fs.promises.appendFile(this.path, '', 'utf8')
71
+ const stats = await fs.promises.stat(this.path)
72
+ this.byte_offset = Number(stats.size ?? 0)
73
+ this.pending_line = ''
74
+ this.running = true
75
+ this.listener_task = this.listenLoop()
76
+ }
77
+
78
+ async close(): Promise<void> {
79
+ this.running = false
80
+ await Promise.allSettled(this.listener_task ? [this.listener_task] : [])
81
+ this.listener_task = null
82
+ this.inbound_bus.destroy()
83
+ }
84
+
85
+ private ensureStarted(): void {
86
+ if (this.running || this.listener_task) return
87
+ void this.start().catch((error: unknown) => {
88
+ console.error('[abxbus] JSONLEventBridge failed to start', error)
89
+ })
90
+ }
91
+
92
+ private async listenLoop(): Promise<void> {
93
+ while (this.running) {
94
+ try {
95
+ await this.pollNewLines()
96
+ } catch {
97
+ // Keep polling on transient errors.
98
+ }
99
+ await new Promise((resolve) => setTimeout(resolve, Math.max(1, this.poll_interval * 1000)))
100
+ }
101
+ }
102
+
103
+ private async pollNewLines(): Promise<void> {
104
+ const previous_offset = this.byte_offset
105
+ const { chunk, next_offset } = await this.readAppended(previous_offset)
106
+ this.byte_offset = next_offset
107
+ if (next_offset < previous_offset) {
108
+ this.pending_line = ''
109
+ }
110
+ if (!chunk) return
111
+
112
+ const new_lines = (this.pending_line + chunk).split('\n')
113
+ this.pending_line = new_lines.pop() ?? ''
114
+
115
+ for (const line of new_lines) {
116
+ const trimmed = line.trim()
117
+ if (!trimmed) continue
118
+ try {
119
+ const payload = JSON.parse(trimmed)
120
+ await this.dispatchInboundPayload(payload)
121
+ } catch {
122
+ // Ignore malformed line.
123
+ }
124
+ }
125
+ }
126
+
127
+ private async dispatchInboundPayload(payload: unknown): Promise<void> {
128
+ const event = BaseEvent.fromJSON(payload).eventReset()
129
+ this.inbound_bus.emit(event)
130
+ }
131
+
132
+ private async readAppended(offset: number): Promise<{ chunk: string; next_offset: number }> {
133
+ const fs = await this.loadFs()
134
+ let size = 0
135
+ try {
136
+ const stats = await fs.promises.stat(this.path)
137
+ size = Number(stats.size ?? 0)
138
+ } catch (error: unknown) {
139
+ const code = (error as { code?: string }).code
140
+ if (code === 'ENOENT') {
141
+ return { chunk: '', next_offset: 0 }
142
+ }
143
+ throw error
144
+ }
145
+
146
+ const start_offset = size < offset ? 0 : offset
147
+ if (size === start_offset) {
148
+ return { chunk: '', next_offset: size }
149
+ }
150
+
151
+ const handle = await fs.promises.open(this.path, 'r')
152
+ try {
153
+ const byte_count = size - start_offset
154
+ const bytes = new Uint8Array(byte_count)
155
+ const { bytesRead } = await handle.read(bytes, 0, byte_count, start_offset)
156
+ const chunk = new TextDecoder().decode(bytes.subarray(0, Number(bytesRead ?? 0)))
157
+ return { chunk, next_offset: start_offset + Number(bytesRead ?? 0) }
158
+ } finally {
159
+ await handle.close()
160
+ }
161
+ }
162
+
163
+ private dirname(path: string): string {
164
+ const idx = path.lastIndexOf('/')
165
+ return idx >= 0 ? path.slice(0, idx) || '.' : '.'
166
+ }
167
+
168
+ private async loadFs(): Promise<any> {
169
+ if (!isNodeRuntime()) {
170
+ throw new Error('JSONLEventBridge is only supported in Node.js runtimes')
171
+ }
172
+ return importNodeModule('node:fs')
173
+ }
174
+ }
@@ -0,0 +1,104 @@
1
+ import { BaseEvent } from './base_event.js'
2
+ import { EventBus } from './event_bus.js'
3
+ import { assertOptionalDependencyAvailable, importOptionalDependency, 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
+
8
+ export class NATSEventBridge {
9
+ readonly server: string
10
+ readonly subject: string
11
+ readonly name: string
12
+
13
+ private readonly inbound_bus: EventBus
14
+ private running: boolean
15
+ private nc: any | null
16
+ private sub_task: Promise<void> | null
17
+
18
+ constructor(server: string, subject: string, name?: string) {
19
+ assertOptionalDependencyAvailable('NATSEventBridge', 'nats')
20
+
21
+ this.server = server
22
+ this.subject = subject
23
+ this.name = name ?? `NATSEventBridge_${randomSuffix()}`
24
+ this.inbound_bus = new EventBus(this.name, { max_history_size: 0 })
25
+ this.running = false
26
+ this.nc = null
27
+ this.sub_task = null
28
+
29
+ this.dispatch = this.dispatch.bind(this)
30
+ this.emit = this.emit.bind(this)
31
+ this.on = this.on.bind(this)
32
+ }
33
+
34
+ on<T extends BaseEvent>(event_pattern: EventClass<T>, handler: EventHandlerCallable<T>): void
35
+ on<T extends BaseEvent>(event_pattern: string | '*', handler: UntypedEventHandlerFunction<T>): void
36
+ on(event_pattern: EventPattern | '*', handler: EventHandlerCallable | UntypedEventHandlerFunction): void {
37
+ this.ensureStarted()
38
+ if (typeof event_pattern === 'string') {
39
+ this.inbound_bus.on(event_pattern, handler as UntypedEventHandlerFunction<BaseEvent>)
40
+ return
41
+ }
42
+ this.inbound_bus.on(event_pattern as EventClass<BaseEvent>, handler as EventHandlerCallable<BaseEvent>)
43
+ }
44
+
45
+ async emit<T extends BaseEvent>(event: T): Promise<void> {
46
+ this.ensureStarted()
47
+ if (!this.nc) await this.start()
48
+
49
+ const payload = JSON.stringify(event.toJSON())
50
+ this.nc.publish(this.subject, new TextEncoder().encode(payload))
51
+ }
52
+
53
+ async dispatch<T extends BaseEvent>(event: T): Promise<void> {
54
+ return this.emit(event)
55
+ }
56
+
57
+ async start(): Promise<void> {
58
+ if (this.running) return
59
+ if (!isNodeRuntime()) {
60
+ throw new Error('NATSEventBridge is only supported in Node.js runtimes')
61
+ }
62
+
63
+ const mod = await importOptionalDependency('NATSEventBridge', 'nats')
64
+ const connect = mod.connect
65
+ this.nc = await connect({ servers: this.server })
66
+ const sub = this.nc.subscribe(this.subject)
67
+
68
+ this.running = true
69
+ this.sub_task = (async () => {
70
+ for await (const msg of sub) {
71
+ try {
72
+ const payload = JSON.parse(new TextDecoder().decode(msg.data))
73
+ await this.dispatchInboundPayload(payload)
74
+ } catch {
75
+ // Ignore malformed payloads.
76
+ }
77
+ }
78
+ })()
79
+ }
80
+
81
+ async close(): Promise<void> {
82
+ this.running = false
83
+ if (this.nc) {
84
+ await this.nc.drain()
85
+ await this.nc.close()
86
+ this.nc = null
87
+ }
88
+ await Promise.allSettled(this.sub_task ? [this.sub_task] : [])
89
+ this.sub_task = null
90
+ this.inbound_bus.destroy()
91
+ }
92
+
93
+ private ensureStarted(): void {
94
+ if (this.running) return
95
+ void this.start().catch((error: unknown) => {
96
+ console.error('[abxbus] NATSEventBridge failed to start', error)
97
+ })
98
+ }
99
+
100
+ private async dispatchInboundPayload(payload: unknown): Promise<void> {
101
+ const event = BaseEvent.fromJSON(payload).eventReset()
102
+ this.inbound_bus.emit(event)
103
+ }
104
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * PostgreSQL LISTEN/NOTIFY + flat-table bridge for forwarding events.
3
+ */
4
+ import { BaseEvent } from './base_event.js'
5
+ import { EventBus } from './event_bus.js'
6
+ import { assertOptionalDependencyAvailable, importOptionalDependency, isNodeRuntime } from './optional_deps.js'
7
+ import type { EventClass, EventHandlerCallable, EventPattern, UntypedEventHandlerFunction } from './types.js'
8
+
9
+ const randomSuffix = (): string => Math.random().toString(36).slice(2, 10)
10
+ const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
11
+ const DEFAULT_POSTGRES_TABLE = 'abxbus_events'
12
+ const DEFAULT_POSTGRES_CHANNEL = 'abxbus_events'
13
+ const EVENT_PAYLOAD_COLUMN = 'event_payload'
14
+
15
+ const validateIdentifier = (value: string, label: string): string => {
16
+ if (!IDENTIFIER_RE.test(value)) {
17
+ throw new Error(`Invalid ${label}: ${JSON.stringify(value)}. Use only [A-Za-z0-9_] and start with a letter/_`)
18
+ }
19
+ return value
20
+ }
21
+
22
+ const indexName = (table: string, suffix: string): string => validateIdentifier(`${table}_${suffix}`.slice(0, 63), 'index name')
23
+
24
+ const parseTableUrl = (table_url: string): { dsn: string; table: string } => {
25
+ let parsed: URL
26
+ try {
27
+ parsed = new URL(table_url)
28
+ } catch {
29
+ throw new Error(
30
+ 'PostgresEventBridge URL must include at least database in path, e.g. postgresql://user:pass@host:5432/dbname[/tablename]'
31
+ )
32
+ }
33
+
34
+ const segments = parsed.pathname.split('/').filter(Boolean)
35
+ if (segments.length < 1) {
36
+ throw new Error(
37
+ 'PostgresEventBridge URL must include at least database in path, e.g. postgresql://user:pass@host:5432/dbname[/tablename]'
38
+ )
39
+ }
40
+
41
+ const db_name = segments[0]
42
+ const table = segments.length >= 2 ? validateIdentifier(segments[1], 'table name') : DEFAULT_POSTGRES_TABLE
43
+ const dsn_url = new URL(parsed.toString())
44
+ dsn_url.pathname = `/${db_name}`
45
+ return { dsn: dsn_url.toString(), table }
46
+ }
47
+
48
+ const splitBridgePayload = (
49
+ payload: Record<string, unknown>
50
+ ): { event_fields: Record<string, unknown>; event_payload: Record<string, unknown> } => {
51
+ const event_fields: Record<string, unknown> = {}
52
+ const event_payload: Record<string, unknown> = { ...payload }
53
+ for (const [key, value] of Object.entries(payload)) {
54
+ if (key.startsWith('event_')) {
55
+ event_fields[key] = value
56
+ }
57
+ }
58
+ return { event_fields, event_payload }
59
+ }
60
+
61
+ export class PostgresEventBridge {
62
+ readonly table_url: string
63
+ readonly dsn: string
64
+ readonly table: string
65
+ readonly channel: string
66
+ readonly name: string
67
+
68
+ private readonly inbound_bus: EventBus
69
+ private running: boolean
70
+ private client: any | null
71
+ private table_columns: Set<string>
72
+ private notification_handler: ((msg: { channel: string; payload?: string }) => void) | null
73
+
74
+ constructor(table_url: string, channel?: string, name?: string) {
75
+ assertOptionalDependencyAvailable('PostgresEventBridge', 'pg')
76
+
77
+ const parsed = parseTableUrl(table_url)
78
+ this.table_url = table_url
79
+ this.dsn = parsed.dsn
80
+ this.table = parsed.table
81
+
82
+ const derived_channel = channel ?? DEFAULT_POSTGRES_CHANNEL
83
+ this.channel = validateIdentifier(derived_channel.slice(0, 63), 'channel name')
84
+ this.name = name ?? `PostgresEventBridge_${randomSuffix()}`
85
+
86
+ this.inbound_bus = new EventBus(this.name, { max_history_size: 0 })
87
+ this.running = false
88
+ this.client = null
89
+ this.table_columns = new Set(['event_id', 'event_created_at', 'event_type', EVENT_PAYLOAD_COLUMN])
90
+ this.notification_handler = null
91
+
92
+ this.dispatch = this.dispatch.bind(this)
93
+ this.emit = this.emit.bind(this)
94
+ this.on = this.on.bind(this)
95
+ }
96
+
97
+ on<T extends BaseEvent>(event_pattern: EventClass<T>, handler: EventHandlerCallable<T>): void
98
+ on<T extends BaseEvent>(event_pattern: string | '*', handler: UntypedEventHandlerFunction<T>): void
99
+ on(event_pattern: EventPattern | '*', handler: EventHandlerCallable | UntypedEventHandlerFunction): void {
100
+ this.ensureStarted()
101
+ if (typeof event_pattern === 'string') {
102
+ this.inbound_bus.on(event_pattern, handler as UntypedEventHandlerFunction<BaseEvent>)
103
+ return
104
+ }
105
+ this.inbound_bus.on(event_pattern as EventClass<BaseEvent>, handler as EventHandlerCallable<BaseEvent>)
106
+ }
107
+
108
+ async emit<T extends BaseEvent>(event: T): Promise<void> {
109
+ this.ensureStarted()
110
+ if (!this.client) await this.start()
111
+
112
+ const payload = event.toJSON() as Record<string, unknown>
113
+ const { event_fields, event_payload } = splitBridgePayload(payload)
114
+ const write_payload: Record<string, unknown> = { ...event_fields, [EVENT_PAYLOAD_COLUMN]: event_payload }
115
+ const keys = Object.keys(write_payload).sort()
116
+ await this.ensureColumns(keys)
117
+
118
+ const columns_sql = keys.map((key) => `"${key}"`).join(', ')
119
+ const placeholders_sql = keys.map((_, index) => `$${index + 1}`).join(', ')
120
+ const values = keys.map((key) =>
121
+ write_payload[key] === null || write_payload[key] === undefined ? null : JSON.stringify(write_payload[key])
122
+ )
123
+
124
+ const update_fields = keys.filter((key) => key !== 'event_id')
125
+ let upsert_sql = `INSERT INTO "${this.table}" (${columns_sql}) VALUES (${placeholders_sql})`
126
+ if (update_fields.length > 0) {
127
+ const updates_sql = update_fields.map((key) => `"${key}" = EXCLUDED."${key}"`).join(', ')
128
+ upsert_sql += ` ON CONFLICT ("event_id") DO UPDATE SET ${updates_sql}`
129
+ } else {
130
+ upsert_sql += ' ON CONFLICT ("event_id") DO NOTHING'
131
+ }
132
+
133
+ await this.client.query(upsert_sql, values)
134
+ await this.client.query('SELECT pg_notify($1, $2)', [this.channel, JSON.stringify(String(event.event_id))])
135
+ }
136
+
137
+ async dispatch<T extends BaseEvent>(event: T): Promise<void> {
138
+ return this.emit(event)
139
+ }
140
+
141
+ async start(): Promise<void> {
142
+ if (this.running) return
143
+ if (!isNodeRuntime()) {
144
+ throw new Error('PostgresEventBridge is only supported in Node.js runtimes')
145
+ }
146
+
147
+ const mod = await importOptionalDependency('PostgresEventBridge', 'pg')
148
+ const Client = mod.Client ?? mod.default?.Client
149
+ this.client = new Client({ connectionString: this.dsn })
150
+ this.client.on('error', () => {})
151
+ await this.client.connect()
152
+
153
+ await this.ensureTableExists()
154
+ await this.refreshColumnCache()
155
+ await this.ensureColumns(['event_id', 'event_created_at', 'event_type', EVENT_PAYLOAD_COLUMN])
156
+ await this.ensureBaseIndexes()
157
+
158
+ this.notification_handler = (msg: { channel: string; payload?: string }) => {
159
+ if (msg.channel !== this.channel || !msg.payload) return
160
+ void this.dispatchByEventId(msg.payload).catch(() => {
161
+ // Ignore transient shutdown races while closing connections.
162
+ })
163
+ }
164
+
165
+ this.client.on('notification', this.notification_handler)
166
+ await this.client.query(`LISTEN ${this.channel}`)
167
+ this.running = true
168
+ }
169
+
170
+ async close(): Promise<void> {
171
+ this.running = false
172
+ if (this.client) {
173
+ try {
174
+ await this.client.query(`UNLISTEN ${this.channel}`)
175
+ } catch {
176
+ // ignore
177
+ }
178
+ if (this.notification_handler) {
179
+ this.client.off('notification', this.notification_handler)
180
+ this.notification_handler = null
181
+ }
182
+ await this.client.end()
183
+ this.client = null
184
+ }
185
+ this.inbound_bus.destroy()
186
+ }
187
+
188
+ private ensureStarted(): void {
189
+ if (this.running) return
190
+ void this.start().catch((error: unknown) => {
191
+ console.error('[abxbus] PostgresEventBridge failed to start', error)
192
+ })
193
+ }
194
+
195
+ private async dispatchByEventId(event_id: string): Promise<void> {
196
+ if (!this.running || !this.client) return
197
+ const result = await this.client.query(`SELECT * FROM "${this.table}" WHERE "event_id" = $1`, [event_id])
198
+ const row = result.rows?.[0] as Record<string, unknown> | undefined
199
+ if (!row) return
200
+
201
+ const payload: Record<string, unknown> = {}
202
+ const raw_event_payload = row[EVENT_PAYLOAD_COLUMN]
203
+ if (typeof raw_event_payload === 'string') {
204
+ try {
205
+ const decoded_event_payload = JSON.parse(raw_event_payload)
206
+ if (decoded_event_payload && typeof decoded_event_payload === 'object' && !Array.isArray(decoded_event_payload)) {
207
+ Object.assign(payload, decoded_event_payload as Record<string, unknown>)
208
+ }
209
+ } catch {
210
+ // ignore malformed payload column
211
+ }
212
+ }
213
+
214
+ for (const [key, raw_value] of Object.entries(row)) {
215
+ if (key === EVENT_PAYLOAD_COLUMN || !key.startsWith('event_')) continue
216
+ if (raw_value === null || raw_value === undefined) continue
217
+ if (typeof raw_value !== 'string') {
218
+ payload[key] = raw_value
219
+ continue
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
+ private async dispatchInboundPayload(payload: unknown): Promise<void> {
232
+ const event = BaseEvent.fromJSON(payload).eventReset()
233
+ this.inbound_bus.emit(event)
234
+ }
235
+
236
+ private async ensureTableExists(): Promise<void> {
237
+ if (!this.client) return
238
+ await this.client.query(
239
+ `CREATE TABLE IF NOT EXISTS "${this.table}" ("event_id" TEXT PRIMARY KEY, "event_created_at" TEXT, "event_type" TEXT, "event_payload" TEXT)`
240
+ )
241
+ }
242
+
243
+ private async ensureBaseIndexes(): Promise<void> {
244
+ if (!this.client) return
245
+
246
+ const event_created_at_idx = indexName(this.table, 'event_created_at_idx')
247
+ const event_type_idx = indexName(this.table, 'event_type_idx')
248
+
249
+ await this.client.query(`CREATE INDEX IF NOT EXISTS "${event_created_at_idx}" ON "${this.table}" ("event_created_at")`)
250
+ await this.client.query(`CREATE INDEX IF NOT EXISTS "${event_type_idx}" ON "${this.table}" ("event_type")`)
251
+ }
252
+
253
+ private async refreshColumnCache(): Promise<void> {
254
+ if (!this.client) return
255
+ const result = await this.client.query(
256
+ `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
257
+ [this.table]
258
+ )
259
+ this.table_columns = new Set((result.rows as Array<{ column_name: string }>).map((row) => row.column_name))
260
+ }
261
+
262
+ private async ensureColumns(keys: string[]): Promise<void> {
263
+ if (!this.client) return
264
+ for (const key of keys) {
265
+ validateIdentifier(key, 'event field name')
266
+ if (key !== EVENT_PAYLOAD_COLUMN && !key.startsWith('event_')) {
267
+ throw new Error(`Invalid event field name for bridge column: ${JSON.stringify(key)}. Only event_* fields become columns`)
268
+ }
269
+ }
270
+
271
+ const missing = keys.filter((key) => !this.table_columns.has(key))
272
+ for (const key of missing) {
273
+ await this.client.query(`ALTER TABLE "${this.table}" ADD COLUMN IF NOT EXISTS "${key}" TEXT`)
274
+ this.table_columns.add(key)
275
+ }
276
+ }
277
+ }