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.
- 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/index.d.ts +2 -0
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/index.js.map +2 -2
- package/dist/cjs/middleware_otel_tracing.d.ts +28 -0
- package/dist/cjs/middleware_otel_tracing.js +189 -0
- package/dist/cjs/middleware_otel_tracing.js.map +7 -0
- 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/index.js +2 -0
- package/dist/esm/index.js.map +2 -2
- package/dist/esm/middleware_otel_tracing.js +169 -0
- package/dist/esm/middleware_otel_tracing.js.map +7 -0
- 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/index.d.ts +2 -0
- package/dist/types/middleware_otel_tracing.d.ts +28 -0
- package/package.json +6 -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 +201 -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,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
|
+
}
|