@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.
- package/README.md +176 -0
- package/dist/adapter.d.ts +89 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +29 -0
- package/dist/adapter.js.map +1 -0
- package/dist/adapters/bun-sql.d.ts +23 -0
- package/dist/adapters/bun-sql.d.ts.map +1 -0
- package/dist/adapters/bun-sql.js +175 -0
- package/dist/adapters/bun-sql.js.map +1 -0
- package/dist/adapters/pg.d.ts +24 -0
- package/dist/adapters/pg.d.ts.map +1 -0
- package/dist/adapters/pg.js +156 -0
- package/dist/adapters/pg.js.map +1 -0
- package/dist/adapters/postgres.d.ts +27 -0
- package/dist/adapters/postgres.d.ts.map +1 -0
- package/dist/adapters/postgres.js +99 -0
- package/dist/adapters/postgres.js.map +1 -0
- package/dist/advisory-locks.d.ts +56 -0
- package/dist/advisory-locks.d.ts.map +1 -0
- package/dist/advisory-locks.js +112 -0
- package/dist/advisory-locks.js.map +1 -0
- package/dist/criteria-sql.d.ts +29 -0
- package/dist/criteria-sql.d.ts.map +1 -0
- package/dist/criteria-sql.js +69 -0
- package/dist/criteria-sql.js.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres-event-store.d.ts +52 -0
- package/dist/postgres-event-store.d.ts.map +1 -0
- package/dist/postgres-event-store.js +496 -0
- package/dist/postgres-event-store.js.map +1 -0
- package/dist/postgres-snapshot-store.d.ts +34 -0
- package/dist/postgres-snapshot-store.d.ts.map +1 -0
- package/dist/postgres-snapshot-store.js +122 -0
- package/dist/postgres-snapshot-store.js.map +1 -0
- package/dist/postgres.d.ts +34 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +42 -0
- package/dist/postgres.js.map +1 -0
- package/dist/schema.d.ts +96 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +174 -0
- package/dist/schema.js.map +1 -0
- package/package.json +93 -0
- package/src/adapter.ts +104 -0
- package/src/adapters/bun-sql.ts +228 -0
- package/src/adapters/pg.ts +189 -0
- package/src/adapters/postgres.ts +134 -0
- package/src/advisory-locks.ts +139 -0
- package/src/criteria-sql.ts +89 -0
- package/src/errors.ts +47 -0
- package/src/index.ts +56 -0
- package/src/postgres-event-store.ts +593 -0
- package/src/postgres-snapshot-store.ts +153 -0
- package/src/postgres.ts +66 -0
- 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
|
+
}
|