@kronos-ts/postgres 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +176 -0
  2. package/dist/adapter.d.ts +89 -0
  3. package/dist/adapter.d.ts.map +1 -0
  4. package/dist/adapter.js +29 -0
  5. package/dist/adapter.js.map +1 -0
  6. package/dist/adapters/bun-sql.d.ts +23 -0
  7. package/dist/adapters/bun-sql.d.ts.map +1 -0
  8. package/dist/adapters/bun-sql.js +175 -0
  9. package/dist/adapters/bun-sql.js.map +1 -0
  10. package/dist/adapters/pg.d.ts +24 -0
  11. package/dist/adapters/pg.d.ts.map +1 -0
  12. package/dist/adapters/pg.js +156 -0
  13. package/dist/adapters/pg.js.map +1 -0
  14. package/dist/adapters/postgres.d.ts +27 -0
  15. package/dist/adapters/postgres.d.ts.map +1 -0
  16. package/dist/adapters/postgres.js +99 -0
  17. package/dist/adapters/postgres.js.map +1 -0
  18. package/dist/advisory-locks.d.ts +56 -0
  19. package/dist/advisory-locks.d.ts.map +1 -0
  20. package/dist/advisory-locks.js +112 -0
  21. package/dist/advisory-locks.js.map +1 -0
  22. package/dist/criteria-sql.d.ts +29 -0
  23. package/dist/criteria-sql.d.ts.map +1 -0
  24. package/dist/criteria-sql.js +69 -0
  25. package/dist/criteria-sql.js.map +1 -0
  26. package/dist/errors.d.ts +30 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +41 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +26 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/postgres-event-store.d.ts +52 -0
  35. package/dist/postgres-event-store.d.ts.map +1 -0
  36. package/dist/postgres-event-store.js +496 -0
  37. package/dist/postgres-event-store.js.map +1 -0
  38. package/dist/postgres-snapshot-store.d.ts +34 -0
  39. package/dist/postgres-snapshot-store.d.ts.map +1 -0
  40. package/dist/postgres-snapshot-store.js +122 -0
  41. package/dist/postgres-snapshot-store.js.map +1 -0
  42. package/dist/postgres.d.ts +34 -0
  43. package/dist/postgres.d.ts.map +1 -0
  44. package/dist/postgres.js +42 -0
  45. package/dist/postgres.js.map +1 -0
  46. package/dist/schema.d.ts +96 -0
  47. package/dist/schema.d.ts.map +1 -0
  48. package/dist/schema.js +174 -0
  49. package/dist/schema.js.map +1 -0
  50. package/package.json +93 -0
  51. package/src/adapter.ts +104 -0
  52. package/src/adapters/bun-sql.ts +228 -0
  53. package/src/adapters/pg.ts +189 -0
  54. package/src/adapters/postgres.ts +134 -0
  55. package/src/advisory-locks.ts +139 -0
  56. package/src/criteria-sql.ts +89 -0
  57. package/src/errors.ts +47 -0
  58. package/src/index.ts +56 -0
  59. package/src/postgres-event-store.ts +593 -0
  60. package/src/postgres-snapshot-store.ts +153 -0
  61. package/src/postgres.ts +66 -0
  62. package/src/schema.ts +204 -0
@@ -0,0 +1,593 @@
1
+ /**
2
+ * createPostgresEventStore — full EventStorageEngine + EventBus implementation.
3
+ *
4
+ * Plan 12-04 delivered: source, appendEvents, append.
5
+ * Plan 12-05 adds: open (gap-free tailing via xid8 + pg_snapshot_xmin),
6
+ * getHeadPosition, firstToken, latestToken, publish, subscribe.
7
+ *
8
+ * Append path:
9
+ * 1. open transaction at READ COMMITTED
10
+ * 2. acquireWriteLocks for the criteria tags (not event types) so that
11
+ * two writers on the SAME criteria tag serialize, while disjoint-tag
12
+ * writers run in parallel
13
+ * 3. Conflict check: SELECT count(*) WHERE sequence_position > marker AND criteria
14
+ * 4. If conflict count > 0 → throw AppendConditionError (code KR001)
15
+ * 5. INSERT events returning sequence_position for the ConsistencyMarker
16
+ * 6. commit() → COMMIT; afterCommit() → marker; rollback() → fire-and-forget
17
+ * ROLLBACK (synchronous void per the framework contract)
18
+ *
19
+ * Streaming path (open):
20
+ * - Watermark query: (transaction_id, sequence_position) > ($xid, $pos)
21
+ * AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
22
+ * - Wake-up via LISTEN/NOTIFY on `kronos_events_${tables.events}` channel
23
+ * - Fallback to 250ms polling if LISTEN is not supported
24
+ *
25
+ * Note on the stored procedure (buildAppendStoredProcedureDDL): The SP is
26
+ * registered in schema.ts and available on the DB, but this plan uses
27
+ * direct parameterised SQL for the conflict check + INSERT rather than
28
+ * calling the SP. The SP's dynamic-SQL approach has complex $N-rebinding
29
+ * requirements (criteria_params JSONB → USING binding) that are cleaner to
30
+ * handle in TypeScript. Plan 06's review may revisit whether the SP
31
+ * provides a meaningful benefit.
32
+ */
33
+
34
+ import type {
35
+ EventStorageEngine,
36
+ AppendTransaction,
37
+ EventStore,
38
+ } from "@kronos-ts/eventsourcing"
39
+ import { markerAt } from "@kronos-ts/eventsourcing"
40
+ import type { ConsistencyMarker } from "@kronos-ts/eventsourcing"
41
+ import type { SourcingCondition } from "@kronos-ts/eventsourcing"
42
+ import type { SourcingResult } from "@kronos-ts/eventsourcing"
43
+ import type { AppendCondition } from "@kronos-ts/eventsourcing"
44
+ import type {
45
+ EventMessage,
46
+ EventCriteria,
47
+ MessageStream,
48
+ SequencedEvent,
49
+ StreamingCondition,
50
+ TrackingToken,
51
+ } from "@kronos-ts/messaging"
52
+ import { createMessageStream, globalSequenceToken, FIRST_TOKEN } from "@kronos-ts/messaging"
53
+ import { qualifiedNameToString, qualifiedNameFromString } from "@kronos-ts/common"
54
+ import type { Serializer } from "@kronos-ts/common"
55
+ export type { Serializer } from "@kronos-ts/common"
56
+ import type { PostgresAdapter, PostgresAdapterTransaction } from "./adapter.js"
57
+ import { IsolationLevel } from "./adapter.js"
58
+ import { acquireWriteLocks, type LockTarget } from "./advisory-locks.js"
59
+ import { buildCriteriaWhere, encodeTag } from "./criteria-sql.js"
60
+ import { AppendConditionError, isDcbViolation, KRONOS_DCB_VIOLATION_SQLSTATE } from "./errors.js"
61
+ import { type TableNames, DEFAULT_TABLE_NAMES } from "./schema.js"
62
+
63
+ // Minimal TagResolver structural shape — the real slot is declared in the
64
+ // core; we accept anything compatible. Serializer uses the canonical type.
65
+ export interface TagResolver {
66
+ resolve(event: EventMessage): ReadonlyArray<{ key: string; value: string }>
67
+ }
68
+
69
+ export interface PostgresEventStoreConfig {
70
+ readonly adapter: PostgresAdapter
71
+ readonly serializer: Serializer
72
+ readonly tagResolver: TagResolver
73
+ readonly tableNames?: TableNames
74
+ }
75
+
76
+ export function createPostgresEventStore(
77
+ config: PostgresEventStoreConfig,
78
+ ): EventStore {
79
+ const { adapter, tagResolver } = config
80
+ const tables = config.tableNames ?? DEFAULT_TABLE_NAMES
81
+
82
+ // Push-based subscriber registry (EventBus.subscribe contract)
83
+ const eventSubscribers = new Set<(events: ReadonlyArray<EventMessage>) => Promise<void>>()
84
+
85
+ // LISTEN/NOTIFY channel name for wake-up of tailing streams (D-12.14)
86
+ const notifyChannel = `kronos_events_${tables.events}`
87
+
88
+ function eventTypeOf(e: EventMessage): string {
89
+ return qualifiedNameToString(e.name)
90
+ }
91
+
92
+ /**
93
+ * Extract lock targets from the criteria — the writer locks on what it is
94
+ * READING (the criteria tags), not just what it is writing. This ensures
95
+ * two writers on the same criteria tag serialize (one blocks until the
96
+ * other commits), while writers on disjoint criteria tags run in parallel.
97
+ *
98
+ * For `any-tag` or empty criteria, returns an empty array so only the
99
+ * global-intent S-lock is acquired (acquireWriteLocks handles the empty case).
100
+ */
101
+ function lockTargetsForCondition(condition: AppendCondition | undefined): LockTarget[] {
102
+ if (!condition) return []
103
+ return extractCriteriaTags(condition.criteria).map((tag) => ({ type: "", tag }))
104
+ }
105
+
106
+ function extractCriteriaTags(criteria: EventCriteria): string[] {
107
+ switch (criteria.kind) {
108
+ case "tags":
109
+ return criteria.tags.map((t) => encodeTag(t.key, t.value))
110
+ case "any-tag":
111
+ // any-tag covers all tags — use global-intent only (empty list)
112
+ return []
113
+ case "type-restricted":
114
+ return extractCriteriaTags(criteria.inner)
115
+ case "either":
116
+ return criteria.criteria.flatMap((c) => extractCriteriaTags(c))
117
+ }
118
+ }
119
+
120
+ function encodedTagsOf(e: EventMessage): string[] {
121
+ return tagResolver.resolve(e).map((t) => encodeTag(t.key, t.value))
122
+ }
123
+
124
+ /**
125
+ * Check for DCB conflict and INSERT events, within the caller's transaction.
126
+ * Returns the (position, xid) of the last inserted row.
127
+ *
128
+ * Uses parameterised SQL (no dynamic SQL), so each query is prepared once
129
+ * and has no $N rebinding complexity.
130
+ */
131
+ async function checkAndInsert(
132
+ tx: PostgresAdapterTransaction,
133
+ events: ReadonlyArray<EventMessage>,
134
+ condition: AppendCondition | undefined,
135
+ ): Promise<{ position: bigint; xid: string }> {
136
+ // --- Conflict check ---
137
+ if (condition) {
138
+ const markerPos = condition.marker.position
139
+ const built = buildCriteriaWhere(condition.criteria, 2) // $1 = markerPos
140
+ const sql = `SELECT count(*)::bigint AS cnt FROM ${tables.events}
141
+ WHERE sequence_position > $1 AND (${built.where})`
142
+ const rows = await tx.query<{ cnt: string | number }>(sql, [markerPos, ...built.params])
143
+ const cnt = BigInt(rows[0]?.cnt ?? 0)
144
+ if (cnt > 0n) {
145
+ // Throw with the KR001 code so isDcbViolation() can identify it
146
+ const err = new AppendConditionError(
147
+ `Append condition violated: ${cnt} conflicting event(s) after position ${markerPos}`,
148
+ )
149
+ ;(err as unknown as { code: string }).code = KRONOS_DCB_VIOLATION_SQLSTATE
150
+ throw err
151
+ }
152
+ }
153
+
154
+ // --- Insert events ---
155
+ let lastPosition = -1n
156
+ let lastXid = ""
157
+
158
+ for (const e of events) {
159
+ const encodedTags = encodedTagsOf(e)
160
+ const type = eventTypeOf(e)
161
+ const payload = e.payload ?? {}
162
+ const metadata = e.metadata ?? {}
163
+
164
+ const rows = await tx.query<{ sequence_position: string; transaction_id: string }>(
165
+ `INSERT INTO ${tables.events} (event_id, type, tags, payload, metadata)
166
+ VALUES ($1, $2, $3, $4, $5)
167
+ RETURNING sequence_position, transaction_id`,
168
+ [
169
+ e.identifier,
170
+ type,
171
+ encodedTags,
172
+ JSON.stringify(payload),
173
+ JSON.stringify(metadata),
174
+ ],
175
+ )
176
+ const row = rows[0]
177
+ if (!row) throw new Error("INSERT returned no rows")
178
+ lastPosition = BigInt(row.sequence_position)
179
+ lastXid = row.transaction_id
180
+ }
181
+
182
+ if (lastPosition < 0n) {
183
+ throw new Error("no events were inserted")
184
+ }
185
+ return { position: lastPosition, xid: lastXid }
186
+ }
187
+
188
+ return {
189
+ async source(condition: SourcingCondition): Promise<SourcingResult> {
190
+ const start = condition.start ?? 0n
191
+ const built = buildCriteriaWhere(condition.criteria, 2) // $1 = start
192
+ const sql = `
193
+ SELECT sequence_position, type, tags, payload, metadata
194
+ FROM ${tables.events}
195
+ WHERE sequence_position >= $1 AND (${built.where})
196
+ ORDER BY sequence_position ASC
197
+ `
198
+ const rows = await adapter.query<{
199
+ sequence_position: string
200
+ type: string
201
+ tags: string[]
202
+ payload: unknown
203
+ metadata: unknown
204
+ }>(sql, [start, ...built.params])
205
+
206
+ const events: EventMessage[] = rows.map((r) => decodeEvent(r))
207
+ const headRow = await adapter.queryOne<{ head: string | null }>(
208
+ `SELECT MAX(sequence_position)::text AS head FROM ${tables.events}`,
209
+ )
210
+ const head = headRow?.head ? BigInt(headRow.head) : -1n
211
+ const lastPos = rows.length > 0 ? BigInt(rows[rows.length - 1]!.sequence_position) : -1n
212
+ const marker = rows.length > 0 ? markerAt(lastPos) : markerAt(head)
213
+ return { events, marker }
214
+ },
215
+
216
+ async appendEvents(
217
+ events: ReadonlyArray<EventMessage>,
218
+ condition?: AppendCondition,
219
+ ): Promise<AppendTransaction> {
220
+ // Two-phase: open tx, acquire locks, run conflict check + INSERT, hold
221
+ // tx open until commit(). We bridge adapter.transaction() (which owns
222
+ // the full lifecycle) with a deferred that the AppendTransaction controls.
223
+ const targets = lockTargetsForCondition(condition)
224
+
225
+ let resolveOuter!: (v: { position: bigint; xid: string }) => void
226
+ let rejectOuter!: (e: unknown) => void
227
+ const outer = new Promise<{ position: bigint; xid: string }>((res, rej) => {
228
+ resolveOuter = res
229
+ rejectOuter = rej
230
+ })
231
+
232
+ let txReady!: () => void
233
+ const txStaged = new Promise<void>((res) => {
234
+ txReady = res
235
+ })
236
+
237
+ let resolveTxControl!: (cmd: "commit" | "rollback") => void
238
+ const txControl = new Promise<"commit" | "rollback">((res) => {
239
+ resolveTxControl = res
240
+ })
241
+
242
+ // Kick off the transaction in the background.
243
+ const txPromise = adapter
244
+ .transaction(IsolationLevel.READ_COMMITTED, async (tx) => {
245
+ await acquireWriteLocks(tx, targets)
246
+ let captured: { position: bigint; xid: string }
247
+ try {
248
+ captured = await checkAndInsert(tx, events, condition)
249
+ } catch (err) {
250
+ if (isDcbViolation(err)) {
251
+ // Already an AppendConditionError with KR001 code
252
+ throw err
253
+ }
254
+ if ((err as { code?: string }).code === "23505") {
255
+ throw AppendConditionError.fromConflictCount(0, condition?.marker.position ?? -1n)
256
+ }
257
+ throw err
258
+ }
259
+ txReady()
260
+ const cmd = await txControl
261
+ if (cmd === "rollback") {
262
+ throw new Error("__kronos_rollback__")
263
+ }
264
+ return captured
265
+ })
266
+ .then(
267
+ (v) => resolveOuter(v),
268
+ (e) => {
269
+ if (e instanceof Error && e.message === "__kronos_rollback__") {
270
+ rejectOuter(new Error("rolled back"))
271
+ } else {
272
+ rejectOuter(e)
273
+ }
274
+ },
275
+ )
276
+ void txPromise
277
+
278
+ // Wait until the SP has executed so that errors (DCB violation) surface
279
+ // BEFORE the AppendTransaction handle is returned.
280
+ await Promise.race([
281
+ txStaged,
282
+ outer.catch(() => {
283
+ return
284
+ }),
285
+ ])
286
+
287
+ // Surface any already-rejected error immediately.
288
+ let alreadyFailed = false
289
+ outer.catch(() => {
290
+ alreadyFailed = true
291
+ })
292
+ await Promise.resolve()
293
+ if (alreadyFailed) {
294
+ await outer
295
+ }
296
+
297
+ let committed = false
298
+ const transaction: AppendTransaction = {
299
+ async commit() {
300
+ committed = true
301
+ resolveTxControl("commit")
302
+ await outer
303
+ // Wake up tailing streams + notify push-based subscribers after commit
304
+ await adapter.query(`NOTIFY ${notifyChannel}`)
305
+ for (const sub of eventSubscribers) {
306
+ try { await sub(events) } catch { /* ignore subscriber errors */ }
307
+ }
308
+ },
309
+ async afterCommit() {
310
+ const result = await outer
311
+ return markerAt(result.position)
312
+ },
313
+ rollback() {
314
+ if (committed) return
315
+ resolveTxControl("rollback")
316
+ outer.catch(() => {
317
+ /* swallow — rollback path is expected to reject outer */
318
+ })
319
+ },
320
+ }
321
+ return transaction
322
+ },
323
+
324
+ async append(
325
+ events: ReadonlyArray<EventMessage>,
326
+ condition?: AppendCondition,
327
+ ): Promise<ConsistencyMarker> {
328
+ // Convenience: appendEvents + commit + afterCommit in one shot.
329
+ const targets = lockTargetsForCondition(condition)
330
+ let marker: ConsistencyMarker
331
+ try {
332
+ marker = await adapter.transaction(IsolationLevel.READ_COMMITTED, async (tx) => {
333
+ await acquireWriteLocks(tx, targets)
334
+ const captured = await checkAndInsert(tx, events, condition)
335
+ return markerAt(captured.position)
336
+ })
337
+ } catch (err) {
338
+ if (isDcbViolation(err)) {
339
+ // Re-throw AppendConditionError directly (already has correct type)
340
+ throw err
341
+ }
342
+ if ((err as { code?: string }).code === "23505") {
343
+ throw AppendConditionError.fromConflictCount(0, condition?.marker.position ?? -1n)
344
+ }
345
+ throw err
346
+ }
347
+ // Wake up tailing streams + notify push-based subscribers
348
+ await adapter.query(`NOTIFY ${notifyChannel}`)
349
+ for (const sub of eventSubscribers) {
350
+ try { await sub(events) } catch { /* ignore subscriber errors */ }
351
+ }
352
+ return marker
353
+ },
354
+
355
+ async getHeadPosition(): Promise<bigint> {
356
+ const row = await adapter.queryOne<{ head: string | null }>(
357
+ `SELECT COALESCE(MAX(sequence_position), 0)::text AS head FROM ${tables.events}`,
358
+ )
359
+ return row?.head ? BigInt(row.head) : 0n
360
+ },
361
+
362
+ async firstToken(): Promise<TrackingToken> {
363
+ return FIRST_TOKEN
364
+ },
365
+
366
+ async latestToken(): Promise<TrackingToken> {
367
+ const row = await adapter.queryOne<{ head: string | null }>(
368
+ `SELECT COALESCE(MAX(sequence_position), 0)::text AS head FROM ${tables.events}`,
369
+ )
370
+ const head = row?.head ? BigInt(row.head) : 0n
371
+ return globalSequenceToken(head)
372
+ },
373
+
374
+ async publish(events: ReadonlyArray<EventMessage>): Promise<void> {
375
+ // publish = append without condition; also notifies subscribers + streams
376
+ const targets: LockTarget[] = []
377
+ let marker: ConsistencyMarker
378
+ try {
379
+ marker = await adapter.transaction(IsolationLevel.READ_COMMITTED, async (tx) => {
380
+ await acquireWriteLocks(tx, targets)
381
+ const captured = await checkAndInsert(tx, events, undefined)
382
+ return markerAt(captured.position)
383
+ })
384
+ } catch (err) {
385
+ if ((err as { code?: string }).code === "23505") {
386
+ throw AppendConditionError.fromConflictCount(0, -1n)
387
+ }
388
+ throw err
389
+ }
390
+ void marker
391
+ await adapter.query(`NOTIFY ${notifyChannel}`)
392
+ for (const sub of eventSubscribers) {
393
+ try { await sub(events) } catch { /* ignore subscriber errors */ }
394
+ }
395
+ },
396
+
397
+ subscribe(
398
+ handler: (events: ReadonlyArray<EventMessage>) => Promise<void>,
399
+ ): () => void {
400
+ eventSubscribers.add(handler)
401
+ return () => {
402
+ eventSubscribers.delete(handler)
403
+ }
404
+ },
405
+
406
+ open(condition: StreamingCondition): MessageStream<SequencedEvent> {
407
+ let cursorPosition = condition.position
408
+ // The (xid8, position) tuple bookmark. We start with xid8 = '0' which is
409
+ // less than any real xid8 — so the (xid8, position) > ($1, $2) predicate
410
+ // collapses to effectively position > $2 on first read.
411
+ let cursorXid = "0"
412
+ const criteria = condition.criteria
413
+ let closed = false
414
+ let onAvailable: (() => void) | null = null
415
+ const buffer: SequencedEvent[] = []
416
+ let polling = false
417
+ let listenSub: { unlisten: () => Promise<void> } | undefined
418
+
419
+ async function fetchBatch(limit = 100): Promise<void> {
420
+ if (closed) return
421
+ // When we have a real xid cursor, use the (xid8, position) tuple comparison
422
+ // for gap-free ordering. On initial fetch (cursorXid = "0") we don't yet
423
+ // have a real xid, so fall back to a plain sequence_position > $cursor filter —
424
+ // the pg_snapshot_xmin watermark still applies to exclude in-flight transactions.
425
+ let sql: string
426
+ let queryParams: unknown[]
427
+
428
+ if (cursorXid === "0") {
429
+ // Initial fetch: simple position filter — all committed events after cursorPosition.
430
+ // $1 = position, criteria starts at $2
431
+ const builtInitial = criteria
432
+ ? buildCriteriaWhere(criteria, 2)
433
+ : { where: "true", params: [] as unknown[], nextParamIndex: 2 }
434
+ const limitParam = builtInitial.nextParamIndex
435
+ sql = `
436
+ SELECT sequence_position::text AS sequence_position,
437
+ transaction_id::text AS transaction_id,
438
+ type, tags, payload, metadata
439
+ FROM ${tables.events}
440
+ WHERE sequence_position > $1::bigint
441
+ AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
442
+ AND (${builtInitial.where})
443
+ ORDER BY transaction_id ASC, sequence_position ASC
444
+ LIMIT $${limitParam}
445
+ `
446
+ queryParams = [String(cursorPosition), ...builtInitial.params, limit]
447
+ } else {
448
+ // Subsequent fetch: (xid8, position) tuple comparison for gap-free ordering.
449
+ // $1 = xid, $2 = position, criteria starts at $3
450
+ const builtTuple = criteria
451
+ ? buildCriteriaWhere(criteria, 3)
452
+ : { where: "true", params: [] as unknown[], nextParamIndex: 3 }
453
+ const limitParam = builtTuple.nextParamIndex
454
+ sql = `
455
+ SELECT sequence_position::text AS sequence_position,
456
+ transaction_id::text AS transaction_id,
457
+ type, tags, payload, metadata
458
+ FROM ${tables.events}
459
+ WHERE (transaction_id, sequence_position) > ($1::xid8, $2::bigint)
460
+ AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
461
+ AND (${builtTuple.where})
462
+ ORDER BY transaction_id ASC, sequence_position ASC
463
+ LIMIT $${limitParam}
464
+ `
465
+ queryParams = [cursorXid, String(cursorPosition), ...builtTuple.params, limit]
466
+ }
467
+
468
+ const rows = await adapter.query<{
469
+ sequence_position: string
470
+ transaction_id: string
471
+ type: string
472
+ tags: string[]
473
+ payload: unknown
474
+ metadata: unknown
475
+ }>(sql, queryParams)
476
+
477
+ for (const r of rows) {
478
+ const event = decodeEvent(r)
479
+ const seq = BigInt(r.sequence_position)
480
+ buffer.push({ sequence: seq, event })
481
+ cursorXid = r.transaction_id
482
+ cursorPosition = seq
483
+ }
484
+ }
485
+
486
+ async function pump(): Promise<void> {
487
+ if (polling || closed) return
488
+ polling = true
489
+ try {
490
+ await fetchBatch()
491
+ if (buffer.length > 0 && onAvailable) onAvailable()
492
+ } finally {
493
+ polling = false
494
+ }
495
+ }
496
+
497
+ // Start polling immediately so we don't miss events that were committed
498
+ // before the LISTEN subscription is established. The poll interval is
499
+ // replaced by NOTIFY-driven pumps once LISTEN is up.
500
+ let pollInterval: ReturnType<typeof setInterval> | undefined = setInterval(() => {
501
+ if (closed) {
502
+ clearInterval(pollInterval)
503
+ pollInterval = undefined
504
+ return
505
+ }
506
+ void pump()
507
+ }, 250)
508
+
509
+ // Wake-up via LISTEN/NOTIFY — supplements polling with instant delivery
510
+ void adapter
511
+ .listen(notifyChannel, () => {
512
+ void pump()
513
+ })
514
+ .then((sub) => {
515
+ listenSub = sub
516
+ // Keep polling as a safety net even with LISTEN active.
517
+ // The 250ms interval is cheap (no-op when no new events).
518
+ })
519
+ .catch(() => {
520
+ // LISTEN not supported — polling fallback already running above
521
+ })
522
+
523
+ // Also run an immediate fetch to pick up any pre-existing events
524
+ void pump()
525
+
526
+ return createMessageStream<SequencedEvent>({
527
+ next() {
528
+ return buffer.shift()
529
+ },
530
+ peek() {
531
+ return buffer[0]
532
+ },
533
+ hasNextAvailable() {
534
+ return buffer.length > 0
535
+ },
536
+ setCallback(cb: () => void) {
537
+ onAvailable = cb
538
+ },
539
+ isCompleted() {
540
+ return closed
541
+ },
542
+ error() {
543
+ return undefined
544
+ },
545
+ close() {
546
+ closed = true
547
+ onAvailable = null
548
+ if (pollInterval) {
549
+ clearInterval(pollInterval)
550
+ pollInterval = undefined
551
+ }
552
+ if (listenSub) void listenSub.unlisten()
553
+ },
554
+ })
555
+ },
556
+ }
557
+ }
558
+
559
+ function decodeEvent(row: {
560
+ type: string
561
+ tags: string[]
562
+ payload: unknown
563
+ metadata: unknown
564
+ sequence_position: string
565
+ }): EventMessage {
566
+ const qn = qualifiedNameFromString(row.type)
567
+ const tags = row.tags.map((t) => {
568
+ const sep = t.indexOf("")
569
+ return sep >= 0
570
+ ? { key: t.slice(0, sep), value: t.slice(sep + 1) }
571
+ : { key: t, value: "" }
572
+ })
573
+ return {
574
+ name: qn,
575
+ tags,
576
+ payload: decodeJsonb(row.payload),
577
+ metadata: decodeJsonb(row.metadata),
578
+ } as unknown as EventMessage
579
+ }
580
+
581
+ // Adapter-agnostic JSONB decoding: pgAdapter/postgresAdapter return parsed
582
+ // objects, but bunSqlAdapter (Bun.SQL) returns JSONB as a raw string. Normalise
583
+ // here so callers always see a JS object.
584
+ function decodeJsonb(v: unknown): unknown {
585
+ if (typeof v === "string") {
586
+ try {
587
+ return JSON.parse(v)
588
+ } catch {
589
+ return v
590
+ }
591
+ }
592
+ return v
593
+ }