@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
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@kronos-ts/postgres",
3
+ "version": "0.1.0",
4
+ "description": "PostgreSQL extension for Kronos — event store and snapshot store adapters.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "author": "Theo Emanuelsson",
8
+ "homepage": "https://github.com/KronosDB/kronos-ts/tree/main/packages/extensions/postgres#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/KronosDB/kronos-ts.git",
12
+ "directory": "packages/extensions/postgres"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/KronosDB/kronos-ts/issues"
16
+ },
17
+ "keywords": [
18
+ "kronos",
19
+ "event-sourcing",
20
+ "cqrs",
21
+ "dcb",
22
+ "typescript",
23
+ "postgres",
24
+ "postgresql"
25
+ ],
26
+ "sideEffects": false,
27
+ "main": "src/index.ts",
28
+ "types": "src/index.ts",
29
+ "exports": {
30
+ ".": "./src/index.ts",
31
+ "./adapters/pg": "./src/adapters/pg.ts",
32
+ "./adapters/postgres": "./src/adapters/postgres.ts",
33
+ "./adapters/bun-sql": "./src/adapters/bun-sql.ts"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src",
38
+ "!src/**/__tests__",
39
+ "!src/**/*.test.ts",
40
+ "!src/**/*.bench.ts"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.json",
44
+ "clean": "rm -rf dist *.tsbuildinfo"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "main": "./dist/index.js",
49
+ "types": "./dist/index.d.ts",
50
+ "exports": {
51
+ ".": {
52
+ "types": "./dist/index.d.ts",
53
+ "default": "./dist/index.js"
54
+ },
55
+ "./adapters/pg": {
56
+ "types": "./dist/adapters/pg.d.ts",
57
+ "default": "./dist/adapters/pg.js"
58
+ },
59
+ "./adapters/postgres": {
60
+ "types": "./dist/adapters/postgres.d.ts",
61
+ "default": "./dist/adapters/postgres.js"
62
+ },
63
+ "./adapters/bun-sql": {
64
+ "types": "./dist/adapters/bun-sql.d.ts",
65
+ "default": "./dist/adapters/bun-sql.js"
66
+ }
67
+ }
68
+ },
69
+ "dependencies": {
70
+ "@kronos-ts/common": "workspace:*",
71
+ "@kronos-ts/app": "workspace:*",
72
+ "@kronos-ts/eventsourcing": "workspace:*",
73
+ "@kronos-ts/messaging": "workspace:*"
74
+ },
75
+ "peerDependencies": {
76
+ "pg": ">=8.0.0",
77
+ "postgres": ">=3.0.0"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "pg": {
81
+ "optional": true
82
+ },
83
+ "postgres": {
84
+ "optional": true
85
+ }
86
+ },
87
+ "devDependencies": {
88
+ "pg": "^8.20.0",
89
+ "@types/pg": "^8.11.0",
90
+ "postgres": "^3.4.9",
91
+ "testcontainers": "^11.14.0"
92
+ }
93
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Driver-agnostic adapter contract for @kronos-ts/postgres.
3
+ *
4
+ * Per D-12.05 the package itself has zero direct dependency on any specific
5
+ * Postgres client library. Engine code (Plan 04) talks to this interface;
6
+ * adapter implementations (pg, postgres.js, Bun.sql) live under
7
+ * `./adapters/*` and are imported via package sub-path exports (D-12.08).
8
+ *
9
+ * Capability coverage per D-12.07:
10
+ * - query execution (parameterised) -> query / queryOne
11
+ * - transactions with isolation level -> transaction(isolationLevel, fn)
12
+ * - LISTEN-style subscription -> listen(channel, onNotification)
13
+ * - lifecycle -> connect / disconnect
14
+ *
15
+ * Error contract per D-12.12: SQLSTATE on thrown errors as `.code` (string).
16
+ * Adapter implementations MUST NOT swallow / rewrap SQLSTATE-bearing errors
17
+ * — the engine relies on `isDcbViolation(err)` reading `err.code === "KR001"`.
18
+ */
19
+
20
+ /**
21
+ * Postgres isolation levels the engine actually emits. SQL string values are
22
+ * the verbatim Postgres syntax used after `SET TRANSACTION ISOLATION LEVEL`,
23
+ * so adapters can interpolate directly without a lookup table.
24
+ */
25
+ export const IsolationLevel = {
26
+ READ_COMMITTED: "READ COMMITTED",
27
+ REPEATABLE_READ: "REPEATABLE READ",
28
+ SERIALIZABLE: "SERIALIZABLE",
29
+ } as const
30
+ export type IsolationLevel = (typeof IsolationLevel)[keyof typeof IsolationLevel]
31
+
32
+ /** Plain row shape returned by query/queryOne. Adapter implementations cast
33
+ * driver-specific row objects to this. */
34
+ export type QueryRow = Record<string, unknown>
35
+
36
+ /**
37
+ * Active in-transaction handle. Pinned to a single underlying connection so
38
+ * statements never interleave with sibling pool traffic. Intentionally NO
39
+ * nested-transaction method — Plan 04's engine does not need it and savepoints
40
+ * would invite confusion about which level a SQLSTATE error propagates from.
41
+ */
42
+ export interface PostgresAdapterTransaction {
43
+ query<R extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<R[]>
44
+ }
45
+
46
+ /** Handle to a live LISTEN subscription. unlisten() unregisters + releases
47
+ * the dedicated connection (if any). */
48
+ export interface ListenSubscription {
49
+ unlisten(): Promise<void>
50
+ }
51
+
52
+ /**
53
+ * The single interface the engine layer (Plan 04+) consumes. All concurrency
54
+ * (pool sizing, idle eviction, reconnect) lives below this seam — the engine
55
+ * code MUST NOT know about pools.
56
+ */
57
+ export interface PostgresAdapter {
58
+ /**
59
+ * Run a parameterised SQL statement on a pool-borrowed connection.
60
+ * Returns rows; empty array if none. Thrown errors carry SQLSTATE on
61
+ * `.code` unchanged so `isDcbViolation(err)` works at any call site.
62
+ */
63
+ query<R extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<R[]>
64
+
65
+ /**
66
+ * Convenience for single-row queries. Returns `null` if zero rows.
67
+ * Throws if more than one row is returned (caller bug — use query()).
68
+ */
69
+ queryOne<R extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<R | null>
70
+
71
+ /**
72
+ * Open a transaction at the given isolation level, run `fn` against a
73
+ * pinned-connection handle, and COMMIT on resolution or ROLLBACK on
74
+ * rejection. If `fn` throws, the original error is re-thrown after
75
+ * ROLLBACK; rollback failures do NOT mask the original.
76
+ *
77
+ * The framework's AppendTransaction has a synchronous `rollback(): void`
78
+ * (see packages/eventsourcing/src/event-storage-engine.ts) — the
79
+ * implementation translates that into a fire-and-forget reject inside
80
+ * this method's promise, NOT an awaited rollback round-trip.
81
+ */
82
+ transaction<T>(
83
+ isolationLevel: IsolationLevel,
84
+ fn: (tx: PostgresAdapterTransaction) => Promise<T>,
85
+ ): Promise<T>
86
+
87
+ /**
88
+ * Subscribe to `LISTEN <channel>` on a dedicated long-lived connection.
89
+ * Adapter implementations that lack a real LISTEN channel (e.g. Bun.sql
90
+ * pre-1.x) MAY implement a polling shim — the subscription handle is the
91
+ * same regardless. Plan 05's streaming open() uses this to wake up
92
+ * tailers immediately instead of waiting for a poll tick.
93
+ */
94
+ listen(
95
+ channel: string,
96
+ onNotification: (payload: string | undefined) => void,
97
+ ): Promise<ListenSubscription>
98
+
99
+ /** Initialise pool / verify reachability. Idempotent. */
100
+ connect(): Promise<void>
101
+
102
+ /** Drain pool, close LISTEN connections, release sockets. Idempotent. */
103
+ disconnect(): Promise<void>
104
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Bun.sql adapter for @kronos-ts/postgres.
3
+ *
4
+ * Import via the sub-path (Bun runtime only):
5
+ * import { bunSqlAdapter } from "@kronos-ts/postgres/adapters/bun-sql"
6
+ *
7
+ * Requires Bun >= 1.2 for Bun.SQL (sql.transaction()). LISTEN support is
8
+ * feature-detected; if Bun.SQL lacks native LISTEN on the running version,
9
+ * the adapter falls back to a 250ms polling shim — slower wake-up but
10
+ * correct semantics.
11
+ *
12
+ * This file uses `globalThis as { Bun?: ... }` access to avoid a hard
13
+ * compile-time reference to Bun's global, so the package can ship under
14
+ * Node (where this file would never be imported) without TypeScript
15
+ * compilation errors. Users importing the sub-path under Node will get
16
+ * a clear runtime error from the connect() call.
17
+ */
18
+
19
+ import type {
20
+ PostgresAdapter,
21
+ PostgresAdapterTransaction,
22
+ ListenSubscription,
23
+ QueryRow,
24
+ } from "../adapter.js"
25
+ import { IsolationLevel } from "../adapter.js"
26
+
27
+ export interface BunSqlAdapterConfig {
28
+ readonly connectionString: string
29
+ }
30
+
31
+ // Minimal structural type for Bun.SQL we depend on (kept local to avoid
32
+ // a hard dependency on @types/bun).
33
+ interface BunSqlInstance {
34
+ (template: TemplateStringsArray, ...values: unknown[]): Promise<unknown[]>
35
+ unsafe(text: string, params?: unknown[]): Promise<unknown[]>
36
+ begin<T>(
37
+ isolation: string,
38
+ fn: (sql: BunSqlInstance) => Promise<T>,
39
+ ): Promise<T>
40
+ listen?(channel: string, cb: (payload: string) => void): Promise<{ unlisten: () => Promise<void> }>
41
+ close(): Promise<void>
42
+ end(): Promise<void>
43
+ }
44
+
45
+ /**
46
+ * Bun.SQL surfaces SQLSTATE in `err.errno` (not `err.code`). The adapter
47
+ * contract (D-12.12) requires SQLSTATE on `.code` so that `isDcbViolation(err)`
48
+ * works uniformly across all adapters.
49
+ *
50
+ * This helper normalises Bun.SQL errors: if `err.code === "ERR_POSTGRES_SERVER_ERROR"`
51
+ * and `err.errno` is a non-empty string, we copy `errno` to `code` before
52
+ * re-throwing, giving callers the SQLSTATE they expect on `.code`.
53
+ */
54
+ /**
55
+ * Bun.SQL's `.unsafe(text, params)` does not auto-encode JS arrays as Postgres
56
+ * array literals — single-element arrays get unwrapped to their scalar value
57
+ * and bound as TEXT, which fails when the column is TEXT[]. We pre-encode any
58
+ * plain Array param to the canonical `{"v1","v2"}` literal so it round-trips
59
+ * to TEXT[] / array operators (`@>`, `ANY`) correctly.
60
+ *
61
+ * Uint8Array / Buffer are not plain arrays (Array.isArray returns false), so
62
+ * BYTEA params remain untouched.
63
+ */
64
+ function encodeArrayParam(arr: unknown[]): string {
65
+ const escaped = arr.map((v) => {
66
+ if (v === null || v === undefined) return "NULL"
67
+ const s = String(v)
68
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
69
+ })
70
+ return `{${escaped.join(",")}}`
71
+ }
72
+
73
+ function normalizeParams(params: unknown[] | undefined): unknown[] {
74
+ if (!params) return []
75
+ return params.map((p) => (Array.isArray(p) ? encodeArrayParam(p) : p))
76
+ }
77
+
78
+ function normalizeBunSqlError(err: unknown): never {
79
+ if (
80
+ typeof err === "object" &&
81
+ err !== null &&
82
+ (err as { code?: string }).code === "ERR_POSTGRES_SERVER_ERROR" &&
83
+ typeof (err as { errno?: unknown }).errno === "string" &&
84
+ (err as { errno: string }).errno.length > 0
85
+ ) {
86
+ ;(err as { code: string }).code = (err as { errno: string }).errno
87
+ }
88
+ throw err
89
+ }
90
+
91
+ interface BunSqlConstructor {
92
+ new (config: { url: string }): BunSqlInstance
93
+ }
94
+
95
+ function getBunSql(): BunSqlConstructor {
96
+ const g = globalThis as { Bun?: { SQL?: BunSqlConstructor } }
97
+ if (!g.Bun?.SQL) {
98
+ throw new Error(
99
+ "bunSqlAdapter requires the Bun runtime with built-in Bun.SQL (>= 1.2). " +
100
+ "Detected non-Bun runtime — use pgAdapter or postgresAdapter instead.",
101
+ )
102
+ }
103
+ return g.Bun.SQL
104
+ }
105
+
106
+ export function bunSqlAdapter(config: BunSqlAdapterConfig): PostgresAdapter {
107
+ let sql: BunSqlInstance | undefined
108
+ let disconnected = false
109
+
110
+ function getInstance(): BunSqlInstance {
111
+ if (!sql) {
112
+ const SQL = getBunSql()
113
+ sql = new SQL({ url: config.connectionString })
114
+ }
115
+ return sql
116
+ }
117
+
118
+ return {
119
+ async connect(): Promise<void> {
120
+ const inst = getInstance()
121
+ const rows = await inst.unsafe("SELECT 1 AS ok").catch(normalizeBunSqlError) as Array<{ ok: number }>
122
+ if (rows[0]?.ok !== 1) {
123
+ throw new Error("bunSqlAdapter.connect: unexpected health-check response")
124
+ }
125
+ },
126
+
127
+ async disconnect(): Promise<void> {
128
+ if (disconnected) return
129
+ disconnected = true
130
+ if (sql) {
131
+ // Bun.SQL exposes both close() and end() — try end() first (graceful
132
+ // drain), fall back to close() if end is unavailable.
133
+ try {
134
+ if (typeof (sql as unknown as { end?: () => Promise<void> }).end === "function") {
135
+ await (sql as unknown as { end: () => Promise<void> }).end()
136
+ } else {
137
+ await sql.close()
138
+ }
139
+ } catch {
140
+ /* ignore disconnect errors */
141
+ }
142
+ sql = undefined
143
+ }
144
+ },
145
+
146
+ async query<R extends QueryRow = QueryRow>(text: string, params?: unknown[]): Promise<R[]> {
147
+ const inst = getInstance()
148
+ return inst.unsafe(text, normalizeParams(params)).catch(normalizeBunSqlError) as Promise<R[]>
149
+ },
150
+
151
+ async queryOne<R extends QueryRow = QueryRow>(
152
+ text: string,
153
+ params?: unknown[],
154
+ ): Promise<R | null> {
155
+ const rows = await this.query<R>(text, params)
156
+ if (rows.length === 0) return null
157
+ if (rows.length > 1) {
158
+ throw new Error(
159
+ `bunSqlAdapter.queryOne: more than one row returned (got ${rows.length}). Use query() for multi-row results.`,
160
+ )
161
+ }
162
+ return rows[0] ?? null
163
+ },
164
+
165
+ async transaction<T>(
166
+ isolationLevel: IsolationLevel,
167
+ fn: (tx: PostgresAdapterTransaction) => Promise<T>,
168
+ ): Promise<T> {
169
+ const inst = getInstance()
170
+ // Bun.SQL.begin(isolation, fn) starts a transaction at the given isolation
171
+ // level. The callback receives a scoped sql instance pinned to the
172
+ // underlying connection. SQLSTATE errors from within the transaction are
173
+ // normalized (errno -> code) via normalizeBunSqlError before propagating.
174
+ return inst.begin(`ISOLATION LEVEL ${isolationLevel}`, async (txSql) => {
175
+ const tx: PostgresAdapterTransaction = {
176
+ async query<R extends QueryRow = QueryRow>(
177
+ text: string,
178
+ params?: unknown[],
179
+ ): Promise<R[]> {
180
+ return txSql.unsafe(text, normalizeParams(params)).catch(normalizeBunSqlError) as Promise<R[]>
181
+ },
182
+ }
183
+ return fn(tx)
184
+ }).catch(normalizeBunSqlError)
185
+ },
186
+
187
+ async listen(
188
+ channel: string,
189
+ onNotification: (payload: string | undefined) => void,
190
+ ): Promise<ListenSubscription> {
191
+ if (!/^[A-Za-z0-9_]+$/.test(channel)) {
192
+ throw new Error(
193
+ `bunSqlAdapter.listen: channel name must match /^[A-Za-z0-9_]+$/, got: ${channel}`,
194
+ )
195
+ }
196
+ const inst = getInstance()
197
+ // Feature-detect native LISTEN
198
+ if (typeof inst.listen === "function") {
199
+ const sub = await inst.listen(channel, (p) => onNotification(p || undefined))
200
+ return {
201
+ async unlisten() {
202
+ await sub.unlisten()
203
+ },
204
+ }
205
+ }
206
+ // Polling fallback for Bun versions without native LISTEN.
207
+ // The fallback fires the callback on every tick with undefined payload,
208
+ // which causes the streaming engine to re-poll. This is coarse but
209
+ // semantically correct — the caller (open() in postgres-event-store.ts)
210
+ // already falls back to 250ms polling as a safety net, so this shim
211
+ // merely triggers additional pump cycles.
212
+ let stopped = false
213
+ const tick = setInterval(() => {
214
+ if (stopped) {
215
+ clearInterval(tick)
216
+ return
217
+ }
218
+ onNotification(undefined)
219
+ }, 250)
220
+ return {
221
+ async unlisten() {
222
+ stopped = true
223
+ clearInterval(tick)
224
+ },
225
+ }
226
+ },
227
+ }
228
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * pg (node-postgres 8.20+) reference adapter for @kronos-ts/postgres.
3
+ *
4
+ * Import via the sub-path:
5
+ * import { pgAdapter } from "@kronos-ts/postgres/adapters/pg"
6
+ *
7
+ * Implements every PostgresAdapter method per the contract in
8
+ * ../adapter.ts. Pool sizing follows pg defaults; override via the
9
+ * `poolConfig` field if needed.
10
+ *
11
+ * LISTEN uses a dedicated long-lived PoolClient pinned outside the pool
12
+ * (never released) so notification delivery is not racing pool eviction.
13
+ * That client is closed by `disconnect()`.
14
+ */
15
+
16
+ import { Pool, type PoolClient, type PoolConfig } from "pg"
17
+ import type {
18
+ PostgresAdapter,
19
+ PostgresAdapterTransaction,
20
+ ListenSubscription,
21
+ QueryRow,
22
+ } from "../adapter.js"
23
+ import { IsolationLevel } from "../adapter.js"
24
+
25
+ export interface PgAdapterConfig {
26
+ /** Standard libpq URI: postgresql://user:pass@host:port/db */
27
+ readonly connectionString: string
28
+ /** Optional pg.Pool config overrides (max connections, idleTimeoutMillis, etc.). */
29
+ readonly poolConfig?: Omit<PoolConfig, "connectionString">
30
+ }
31
+
32
+ interface ListenerSlot {
33
+ channel: string
34
+ callback: (payload: string | undefined) => void
35
+ }
36
+
37
+ export function pgAdapter(config: PgAdapterConfig): PostgresAdapter {
38
+ let pool: Pool | undefined
39
+ let listenClient: PoolClient | undefined
40
+ const listenSlots = new Map<string, Set<ListenerSlot>>()
41
+ let disconnected = false
42
+
43
+ function getPool(): Pool {
44
+ if (!pool) {
45
+ pool = new Pool({ connectionString: config.connectionString, ...config.poolConfig })
46
+ pool.on("error", () => {
47
+ // Connection-level errors on idle clients are non-fatal for the
48
+ // adapter; pg removes the bad client from the pool automatically.
49
+ // Surfacing them would force every consumer to attach a handler.
50
+ })
51
+ }
52
+ return pool
53
+ }
54
+
55
+ async function ensureListenClient(): Promise<PoolClient> {
56
+ if (listenClient) return listenClient
57
+ listenClient = await getPool().connect()
58
+ listenClient.on("notification", (msg) => {
59
+ const slots = listenSlots.get(msg.channel)
60
+ if (!slots) return
61
+ for (const slot of slots) slot.callback(msg.payload)
62
+ })
63
+ return listenClient
64
+ }
65
+
66
+ const adapter: PostgresAdapter = {
67
+ async connect(): Promise<void> {
68
+ // Force pool creation + a no-op query so connect() actually verifies
69
+ // reachability. Without this, a bad connection string would only
70
+ // surface on the first real query.
71
+ const result = await getPool().query<{ ok: number }>("SELECT 1 AS ok")
72
+ if (result.rows[0]?.ok !== 1) {
73
+ throw new Error("pgAdapter.connect(): unexpected health-check response")
74
+ }
75
+ },
76
+
77
+ async disconnect(): Promise<void> {
78
+ if (disconnected) return
79
+ disconnected = true
80
+ if (listenClient) {
81
+ try {
82
+ listenClient.release()
83
+ } catch {
84
+ /* ignore — client may already be gone */
85
+ }
86
+ listenClient = undefined
87
+ }
88
+ if (pool) {
89
+ await pool.end()
90
+ pool = undefined
91
+ }
92
+ },
93
+
94
+ async query<R extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<R[]> {
95
+ const result = await getPool().query<R>(sql, params as unknown[])
96
+ return result.rows
97
+ },
98
+
99
+ async queryOne<R extends QueryRow = QueryRow>(
100
+ sql: string,
101
+ params?: unknown[],
102
+ ): Promise<R | null> {
103
+ const result = await getPool().query<R>(sql, params as unknown[])
104
+ if (result.rows.length === 0) return null
105
+ if (result.rows.length > 1) {
106
+ throw new Error(
107
+ `pgAdapter.queryOne: more than one row returned (got ${result.rows.length}). ` +
108
+ `Use query() for multi-row results.`,
109
+ )
110
+ }
111
+ return result.rows[0] ?? null
112
+ },
113
+
114
+ async transaction<T>(
115
+ isolationLevel: IsolationLevel,
116
+ fn: (tx: PostgresAdapterTransaction) => Promise<T>,
117
+ ): Promise<T> {
118
+ const client = await getPool().connect()
119
+ try {
120
+ await client.query(`BEGIN ISOLATION LEVEL ${isolationLevel}`)
121
+ const tx: PostgresAdapterTransaction = {
122
+ async query<R extends QueryRow = QueryRow>(
123
+ sql: string,
124
+ params?: unknown[],
125
+ ): Promise<R[]> {
126
+ const result = await client.query<R>(sql, params as unknown[])
127
+ return result.rows
128
+ },
129
+ }
130
+ let result: T
131
+ try {
132
+ result = await fn(tx)
133
+ } catch (err) {
134
+ // ROLLBACK best-effort; preserve the ORIGINAL error.
135
+ try {
136
+ await client.query("ROLLBACK")
137
+ } catch {
138
+ /* ignore rollback failure — original error matters more */
139
+ }
140
+ throw err
141
+ }
142
+ await client.query("COMMIT")
143
+ return result
144
+ } finally {
145
+ client.release()
146
+ }
147
+ },
148
+
149
+ async listen(
150
+ channel: string,
151
+ onNotification: (payload: string | undefined) => void,
152
+ ): Promise<ListenSubscription> {
153
+ const client = await ensureListenClient()
154
+ const slot: ListenerSlot = { channel, callback: onNotification }
155
+ let slots = listenSlots.get(channel)
156
+ if (!slots) {
157
+ slots = new Set()
158
+ listenSlots.set(channel, slots)
159
+ // Channel identifiers cannot be parameterised in pg's LISTEN — use
160
+ // a safelist: alphanumerics + underscore only. Anything else is a
161
+ // SQL-injection risk by definition.
162
+ if (!/^[A-Za-z0-9_]+$/.test(channel)) {
163
+ throw new Error(
164
+ `pgAdapter.listen: channel name must match /^[A-Za-z0-9_]+$/, got: ${channel}`,
165
+ )
166
+ }
167
+ await client.query(`LISTEN ${channel}`)
168
+ }
169
+ slots.add(slot)
170
+ return {
171
+ async unlisten() {
172
+ const cur = listenSlots.get(channel)
173
+ if (!cur) return
174
+ cur.delete(slot)
175
+ if (cur.size === 0) {
176
+ listenSlots.delete(channel)
177
+ try {
178
+ await client.query(`UNLISTEN ${channel}`)
179
+ } catch {
180
+ /* connection may already be gone */
181
+ }
182
+ }
183
+ },
184
+ }
185
+ },
186
+ }
187
+
188
+ return adapter
189
+ }