@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
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
|
+
}
|