abxbus 2.4.16 → 2.4.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/event_bus.js +1 -1
- package/dist/cjs/event_bus.js.map +2 -2
- package/dist/cjs/event_handler.d.ts +1 -0
- package/dist/cjs/event_handler.js +14 -1
- package/dist/cjs/event_handler.js.map +2 -2
- package/dist/cjs/middleware_otel_tracing.d.ts +9 -1
- package/dist/cjs/middleware_otel_tracing.js +73 -6
- package/dist/cjs/middleware_otel_tracing.js.map +2 -2
- package/dist/cjs/retry.js +39 -0
- package/dist/cjs/retry.js.map +2 -2
- package/dist/esm/event_bus.js +1 -1
- package/dist/esm/event_bus.js.map +2 -2
- package/dist/esm/event_handler.js +14 -1
- package/dist/esm/event_handler.js.map +2 -2
- package/dist/esm/middleware_otel_tracing.js +78 -7
- package/dist/esm/middleware_otel_tracing.js.map +2 -2
- package/dist/esm/retry.js +39 -0
- package/dist/esm/retry.js.map +2 -2
- package/dist/types/event_handler.d.ts +1 -0
- package/dist/types/middleware_otel_tracing.d.ts +9 -1
- package/package.json +5 -1
- package/src/async_context.ts +70 -0
- package/src/base_event.ts +1201 -0
- package/src/bridge_jsonl.ts +174 -0
- package/src/bridge_nats.ts +104 -0
- package/src/bridge_postgres.ts +277 -0
- package/src/bridge_redis.ts +194 -0
- package/src/bridge_sqlite.ts +289 -0
- package/src/bridges.ts +376 -0
- package/src/event_bus.ts +1263 -0
- package/src/event_handler.ts +379 -0
- package/src/event_history.ts +247 -0
- package/src/event_result.ts +483 -0
- package/src/events_suck.ts +96 -0
- package/src/helpers.ts +65 -0
- package/src/index.ts +37 -0
- package/src/lock_manager.ts +401 -0
- package/src/logging.ts +261 -0
- package/src/middleware_otel_tracing.ts +290 -0
- package/src/middlewares.ts +16 -0
- package/src/optional_deps.ts +52 -0
- package/src/retry.ts +578 -0
- package/src/timing.ts +52 -0
- 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
|
+
}
|