@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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postgres.js (porsager) reference adapter for @kronos-ts/postgres.
|
|
3
|
+
*
|
|
4
|
+
* Import via the sub-path:
|
|
5
|
+
* import { postgresAdapter } from "@kronos-ts/postgres/adapters/postgres"
|
|
6
|
+
*
|
|
7
|
+
* Notable porsager/postgres quirks handled here:
|
|
8
|
+
* - Default behaviour transforms column names (snake_case → camelCase).
|
|
9
|
+
* We DISABLE this because the SQL we author (and the SP signatures
|
|
10
|
+
* produced by schema.ts) use snake_case column names — letting the
|
|
11
|
+
* transform fire would produce row objects with `sequencePosition`
|
|
12
|
+
* instead of `sequence_position`, breaking the engine code.
|
|
13
|
+
* - Transactions: sql.begin returns the callback's value when it resolves
|
|
14
|
+
* and ROLLBACKs on rejection. We use the `BEGIN ISOLATION LEVEL ${lvl}`
|
|
15
|
+
* option since sql.begin accepts isolation as part of the BEGIN clause.
|
|
16
|
+
* - LISTEN: sql.listen(channel, cb) returns a Promise<{ unlisten() }>.
|
|
17
|
+
* The shape already matches our ListenSubscription contract.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import postgresClient from "postgres"
|
|
21
|
+
import type { Sql } from "postgres"
|
|
22
|
+
import type {
|
|
23
|
+
PostgresAdapter,
|
|
24
|
+
PostgresAdapterTransaction,
|
|
25
|
+
ListenSubscription,
|
|
26
|
+
QueryRow,
|
|
27
|
+
} from "../adapter.js"
|
|
28
|
+
import { IsolationLevel } from "../adapter.js"
|
|
29
|
+
|
|
30
|
+
export interface PostgresAdapterConfig {
|
|
31
|
+
readonly connectionString: string
|
|
32
|
+
/** Additional postgres.js options. `transform.column.from` is forced off regardless. */
|
|
33
|
+
readonly clientOptions?: Parameters<typeof postgresClient>[1]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function postgresAdapter(config: PostgresAdapterConfig): PostgresAdapter {
|
|
37
|
+
let sql: Sql | undefined
|
|
38
|
+
let disconnected = false
|
|
39
|
+
|
|
40
|
+
function getSql(): Sql {
|
|
41
|
+
if (!sql) {
|
|
42
|
+
sql = postgresClient(config.connectionString, {
|
|
43
|
+
...(config.clientOptions ?? {}),
|
|
44
|
+
// Hard-override the column transform so our snake_case SQL works
|
|
45
|
+
// verbatim. Users who pass a transform in clientOptions are told
|
|
46
|
+
// (via JSDoc) that the column-from transform is ignored.
|
|
47
|
+
transform: {
|
|
48
|
+
...(config.clientOptions?.transform ?? {}),
|
|
49
|
+
column: { from: undefined as never, to: undefined as never },
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
return sql
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
async connect(): Promise<void> {
|
|
58
|
+
const c = getSql()
|
|
59
|
+
const rows = await c.unsafe<Array<{ ok: number }>>("SELECT 1 AS ok")
|
|
60
|
+
if (rows[0]?.ok !== 1) {
|
|
61
|
+
throw new Error("postgresAdapter.connect: unexpected health-check response")
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async disconnect(): Promise<void> {
|
|
66
|
+
if (disconnected) return
|
|
67
|
+
disconnected = true
|
|
68
|
+
if (sql) {
|
|
69
|
+
await sql.end({ timeout: 5 })
|
|
70
|
+
sql = undefined
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async query<R extends QueryRow = QueryRow>(text: string, params?: unknown[]): Promise<R[]> {
|
|
75
|
+
const c = getSql()
|
|
76
|
+
const rows = (await c.unsafe(text, ((params as unknown[]) ?? []) as never[])) as unknown as R[]
|
|
77
|
+
return rows
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async queryOne<R extends QueryRow = QueryRow>(
|
|
81
|
+
text: string,
|
|
82
|
+
params?: unknown[],
|
|
83
|
+
): Promise<R | null> {
|
|
84
|
+
const rows = await this.query<R>(text, params)
|
|
85
|
+
if (rows.length === 0) return null
|
|
86
|
+
if (rows.length > 1) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`postgresAdapter.queryOne: more than one row returned (got ${rows.length}). Use query() for multi-row results.`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
return rows[0] ?? null
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async transaction<T>(
|
|
95
|
+
isolationLevel: IsolationLevel,
|
|
96
|
+
fn: (tx: PostgresAdapterTransaction) => Promise<T>,
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
const c = getSql()
|
|
99
|
+
// sql.begin's first argument is the BEGIN options; we pass the
|
|
100
|
+
// isolation level. The callback receives a scoped `sql` that
|
|
101
|
+
// pins to the underlying connection for the duration.
|
|
102
|
+
return (await c.begin(`ISOLATION LEVEL ${isolationLevel}`, async (txSql) => {
|
|
103
|
+
const tx: PostgresAdapterTransaction = {
|
|
104
|
+
async query<R extends QueryRow = QueryRow>(
|
|
105
|
+
text: string,
|
|
106
|
+
params?: unknown[],
|
|
107
|
+
): Promise<R[]> {
|
|
108
|
+
const rows = (await txSql.unsafe(text, ((params as unknown[]) ?? []) as never[])) as unknown as R[]
|
|
109
|
+
return rows
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
return fn(tx)
|
|
113
|
+
})) as T
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async listen(
|
|
117
|
+
channel: string,
|
|
118
|
+
onNotification: (payload: string | undefined) => void,
|
|
119
|
+
): Promise<ListenSubscription> {
|
|
120
|
+
if (!/^[A-Za-z0-9_]+$/.test(channel)) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`postgresAdapter.listen: channel name must match /^[A-Za-z0-9_]+$/, got: ${channel}`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
const c = getSql()
|
|
126
|
+
const sub = await c.listen(channel, (payload) => onNotification(payload || undefined))
|
|
127
|
+
return {
|
|
128
|
+
async unlisten() {
|
|
129
|
+
await sub.unlisten()
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advisory-lock taxonomy for the DCB conflict check (D-12.09/10/11).
|
|
3
|
+
*
|
|
4
|
+
* Three keyspaces, S/X-asymmetric:
|
|
5
|
+
*
|
|
6
|
+
* Leaf K(T, t) writer = X-lock, reader = S-lock
|
|
7
|
+
* Type-intent K(T, ε) writer = S-lock, reader = X-lock
|
|
8
|
+
* Global-intent K(ε, ε) writer = S-lock, reader = X-lock
|
|
9
|
+
*
|
|
10
|
+
* The asymmetry lets writers on DISJOINT (T,t) tuples run in parallel
|
|
11
|
+
* (their leaf X-locks don't conflict, and they only take S on the intent
|
|
12
|
+
* keys — multiple S-holders coexist). A Query.all() reader (rare) takes
|
|
13
|
+
* X on the intent keys, briefly blocking ALL writers — but that's the
|
|
14
|
+
* point: Query.all() needs to see a consistent snapshot.
|
|
15
|
+
*
|
|
16
|
+
* Locks are pg_advisory_xact_lock variants: held until tx commit/rollback,
|
|
17
|
+
* automatically released. NEVER use session-scoped locks here — PgBouncer
|
|
18
|
+
* in transaction-pooling mode would leak them.
|
|
19
|
+
*
|
|
20
|
+
* Reimplemented from principles per D-12.11. The kraken-tech version is
|
|
21
|
+
* a reference for the taxonomy and FNV-1a hash; the SQL and serialisation
|
|
22
|
+
* format below are original.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { PostgresAdapterTransaction } from "./adapter.js"
|
|
26
|
+
|
|
27
|
+
const UNIT_SEPARATOR = "" // ASCII Unit Separator (U+001F) — prevents tuple-collision hashing
|
|
28
|
+
const KEYSPACE_LEAF = "L"
|
|
29
|
+
const KEYSPACE_TYPE_INTENT = "T"
|
|
30
|
+
const KEYSPACE_GLOBAL_INTENT = "G"
|
|
31
|
+
|
|
32
|
+
// FNV-1a 64-bit constants
|
|
33
|
+
const FNV_OFFSET_BASIS = 0xcbf29ce484222325n
|
|
34
|
+
const FNV_PRIME = 0x100000001b3n
|
|
35
|
+
const MASK_64 = (1n << 64n) - 1n
|
|
36
|
+
const SIGN_THRESHOLD = 1n << 63n
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* FNV-1a 64-bit hash. Returns a BIGINT in the signed 64-bit range so it can
|
|
40
|
+
* be passed directly to `pg_advisory_xact_lock(BIGINT)`. Values with the
|
|
41
|
+
* high bit set are returned as negative numbers (two's complement).
|
|
42
|
+
*
|
|
43
|
+
* Per FNV-1a: h = offset_basis; for each byte b: h = (h XOR b) * prime mod 2^64.
|
|
44
|
+
* UTF-8 byte iteration via TextEncoder for predictable cross-runtime behaviour.
|
|
45
|
+
*/
|
|
46
|
+
export function hashLockKey(input: string): bigint {
|
|
47
|
+
const bytes = new TextEncoder().encode(input)
|
|
48
|
+
let h = FNV_OFFSET_BASIS
|
|
49
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
50
|
+
h = (h ^ BigInt(bytes[i]!)) & MASK_64
|
|
51
|
+
h = (h * FNV_PRIME) & MASK_64
|
|
52
|
+
}
|
|
53
|
+
// Reinterpret as signed 64-bit
|
|
54
|
+
return h >= SIGN_THRESHOLD ? h - (1n << 64n) : h
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type LockKeyspace = "leaf" | "type-intent" | "global-intent"
|
|
58
|
+
|
|
59
|
+
export function leafKey(type: string, tag: string): bigint {
|
|
60
|
+
return hashLockKey(`${KEYSPACE_LEAF}${UNIT_SEPARATOR}${type}${UNIT_SEPARATOR}${tag}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function typeIntentKey(type: string): bigint {
|
|
64
|
+
return hashLockKey(`${KEYSPACE_TYPE_INTENT}${UNIT_SEPARATOR}${type}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function globalIntentKey(): bigint {
|
|
68
|
+
return hashLockKey(KEYSPACE_GLOBAL_INTENT)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface LockTarget {
|
|
72
|
+
readonly type: string
|
|
73
|
+
readonly tag: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Writer pattern: X-lock on each unique leaf, S-lock on each unique
|
|
78
|
+
* type-intent + the global-intent. Writers on disjoint leaves run in
|
|
79
|
+
* parallel; writers sharing a leaf serialise.
|
|
80
|
+
*
|
|
81
|
+
* Locks are issued in a stable order (sorted by key value) to avoid
|
|
82
|
+
* deadlocks between concurrent writers that share multiple leaves.
|
|
83
|
+
*/
|
|
84
|
+
export async function acquireWriteLocks(
|
|
85
|
+
tx: PostgresAdapterTransaction,
|
|
86
|
+
targets: ReadonlyArray<LockTarget>,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
if (targets.length === 0) {
|
|
89
|
+
// Even with no leaf targets, acquire the global-intent S-lock so that
|
|
90
|
+
// a Query.all() X on the global-intent can block us if needed.
|
|
91
|
+
await tx.query(`SELECT pg_advisory_xact_lock_shared($1)`, [globalIntentKey()])
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const leafKeys = uniqueSorted(targets.map((t) => leafKey(t.type, t.tag)))
|
|
96
|
+
const typeIntentKeys = uniqueSorted([...new Set(targets.map((t) => t.type))].map(typeIntentKey))
|
|
97
|
+
|
|
98
|
+
// X on leaves (sorted for deadlock-free acquisition order)
|
|
99
|
+
for (const k of leafKeys) {
|
|
100
|
+
await tx.query(`SELECT pg_advisory_xact_lock($1)`, [k])
|
|
101
|
+
}
|
|
102
|
+
// S on type-intent
|
|
103
|
+
for (const k of typeIntentKeys) {
|
|
104
|
+
await tx.query(`SELECT pg_advisory_xact_lock_shared($1)`, [k])
|
|
105
|
+
}
|
|
106
|
+
// S on global-intent
|
|
107
|
+
await tx.query(`SELECT pg_advisory_xact_lock_shared($1)`, [globalIntentKey()])
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reader pattern (Query.all): S on leaves, X on type-intent + global-intent.
|
|
112
|
+
* Inverse of writer; briefly excludes all writers while held.
|
|
113
|
+
*/
|
|
114
|
+
export async function acquireReadLocks(
|
|
115
|
+
tx: PostgresAdapterTransaction,
|
|
116
|
+
targets: ReadonlyArray<LockTarget>,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
if (targets.length === 0) {
|
|
119
|
+
// A truly empty Query.all() still needs the global-intent X to see a
|
|
120
|
+
// consistent snapshot across all types.
|
|
121
|
+
await tx.query(`SELECT pg_advisory_xact_lock($1)`, [globalIntentKey()])
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const leafKeys = uniqueSorted(targets.map((t) => leafKey(t.type, t.tag)))
|
|
126
|
+
const typeIntentKeys = uniqueSorted([...new Set(targets.map((t) => t.type))].map(typeIntentKey))
|
|
127
|
+
|
|
128
|
+
for (const k of leafKeys) {
|
|
129
|
+
await tx.query(`SELECT pg_advisory_xact_lock_shared($1)`, [k])
|
|
130
|
+
}
|
|
131
|
+
for (const k of typeIntentKeys) {
|
|
132
|
+
await tx.query(`SELECT pg_advisory_xact_lock($1)`, [k])
|
|
133
|
+
}
|
|
134
|
+
await tx.query(`SELECT pg_advisory_xact_lock($1)`, [globalIntentKey()])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function uniqueSorted(xs: ReadonlyArray<bigint>): bigint[] {
|
|
138
|
+
return [...new Set(xs)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
139
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventCriteria → SQL WHERE clause builder.
|
|
3
|
+
*
|
|
4
|
+
* Maps the discriminated-union criteria from @kronos-ts/messaging into a
|
|
5
|
+
* parameterised WHERE fragment + parameter array. The caller (Plan 04
|
|
6
|
+
* source() + the append SP body) splices this into a larger query.
|
|
7
|
+
*
|
|
8
|
+
* Tag semantics: `@>` (contains-all). NEVER `&&` (overlap). The reference
|
|
9
|
+
* is packages/eventsourcing/src/in-memory-event-store.ts:matchesTags —
|
|
10
|
+
* `criteria.tags.every(requiredTag => event.tags.some(...))` is exactly
|
|
11
|
+
* the meaning of `tags @> $required`.
|
|
12
|
+
*
|
|
13
|
+
* Tag encoding: each `{key, value}` is serialised as `${key}${value}`.
|
|
14
|
+
* Stored events use the same encoding (Plan 04 Task 3 — appendEvents flattens
|
|
15
|
+
* tags via the same scheme) so `@>` works literally against text[].
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { EventCriteria } from "@kronos-ts/messaging"
|
|
19
|
+
|
|
20
|
+
export interface CriteriaSQL {
|
|
21
|
+
/** SQL WHERE fragment (no leading "WHERE"). Always truthy — empty
|
|
22
|
+
* criteria collapse to `"true"`. */
|
|
23
|
+
readonly where: string
|
|
24
|
+
/** Parameters in $-positional order. */
|
|
25
|
+
readonly params: ReadonlyArray<unknown>
|
|
26
|
+
/** Next available $N index for chaining into a larger query. */
|
|
27
|
+
readonly nextParamIndex: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TAG_DELIMITER = "" // ASCII Unit Separator (U+001F) — prevents key/value boundary collisions in encoded tag strings
|
|
31
|
+
|
|
32
|
+
export function encodeTag(key: string, value: string): string {
|
|
33
|
+
return `${key}${TAG_DELIMITER}${value}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildCriteriaWhere(
|
|
37
|
+
criteria: EventCriteria,
|
|
38
|
+
startIndex: number,
|
|
39
|
+
): CriteriaSQL {
|
|
40
|
+
switch (criteria.kind) {
|
|
41
|
+
case "any-tag":
|
|
42
|
+
return {
|
|
43
|
+
where: "cardinality(tags) > 0",
|
|
44
|
+
params: [],
|
|
45
|
+
nextParamIndex: startIndex,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case "tags": {
|
|
49
|
+
if (criteria.tags.length === 0) {
|
|
50
|
+
// Empty contains-all matches everything (the in-memory store's
|
|
51
|
+
// `tags.every(...)` over an empty array is vacuously true).
|
|
52
|
+
return { where: "true", params: [], nextParamIndex: startIndex }
|
|
53
|
+
}
|
|
54
|
+
const encoded = criteria.tags.map((t) => encodeTag(t.key, t.value))
|
|
55
|
+
return {
|
|
56
|
+
where: `tags @> $${startIndex}::text[]`,
|
|
57
|
+
params: [encoded],
|
|
58
|
+
nextParamIndex: startIndex + 1,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
case "type-restricted": {
|
|
63
|
+
const inner = buildCriteriaWhere(criteria.inner, startIndex)
|
|
64
|
+
const typeParam = inner.nextParamIndex
|
|
65
|
+
return {
|
|
66
|
+
where: `(${inner.where}) AND type = ANY($${typeParam}::text[])`,
|
|
67
|
+
params: [...inner.params, criteria.types],
|
|
68
|
+
nextParamIndex: typeParam + 1,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
case "either": {
|
|
73
|
+
const parts: string[] = []
|
|
74
|
+
const params: unknown[] = []
|
|
75
|
+
let next = startIndex
|
|
76
|
+
for (const sub of criteria.criteria) {
|
|
77
|
+
const built = buildCriteriaWhere(sub, next)
|
|
78
|
+
parts.push(`(${built.where})`)
|
|
79
|
+
params.push(...built.params)
|
|
80
|
+
next = built.nextParamIndex
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
where: parts.join(" OR "),
|
|
84
|
+
params,
|
|
85
|
+
nextParamIndex: next,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLSTATE used by the schema-bootstrap stored procedure when a DCB
|
|
3
|
+
* append condition is violated. Per D-12.12: dedicated SQLSTATE via
|
|
4
|
+
* `RAISE ... USING ERRCODE`, never error-text parsing.
|
|
5
|
+
*
|
|
6
|
+
* `KR001` lives in the Postgres user-defined SQLSTATE range (KX–ZZ).
|
|
7
|
+
* It is intentionally distinct from:
|
|
8
|
+
* - `P0001` — generic RAISE (would over-match any unhandled plpgsql exception)
|
|
9
|
+
* - `23505` — unique_violation (used by primary key conflicts)
|
|
10
|
+
* Adapter layers translate `err.code === KR001` into AppendConditionError.
|
|
11
|
+
*/
|
|
12
|
+
export const KRONOS_DCB_VIOLATION_SQLSTATE = "KR001"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Thrown when an append condition is violated — optimistic concurrency
|
|
16
|
+
* failure. Structurally mirrors `@kronos-ts/eventsourcing`'s
|
|
17
|
+
* AppendConditionError so callers that catch either get equivalent
|
|
18
|
+
* behaviour, but we ship our own class so that the SQLSTATE-catch
|
|
19
|
+
* boundary lives inside this package's import graph.
|
|
20
|
+
*/
|
|
21
|
+
export class AppendConditionError extends Error {
|
|
22
|
+
constructor(message: string) {
|
|
23
|
+
super(message)
|
|
24
|
+
this.name = "AppendConditionError"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static fromConflictCount(count: number, afterPosition: bigint): AppendConditionError {
|
|
28
|
+
return new AppendConditionError(
|
|
29
|
+
`Append condition violated: ${count} conflicting event(s) ` +
|
|
30
|
+
`found after position ${afterPosition}`,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Adapter-agnostic check: pg and postgres.js both surface SQLSTATE on
|
|
37
|
+
* thrown errors as `.code` (string). Bun.sql follows the same convention.
|
|
38
|
+
* This helper keeps the SQLSTATE constant centralised.
|
|
39
|
+
*/
|
|
40
|
+
export function isDcbViolation(err: unknown): boolean {
|
|
41
|
+
return (
|
|
42
|
+
typeof err === "object" &&
|
|
43
|
+
err !== null &&
|
|
44
|
+
"code" in err &&
|
|
45
|
+
(err as { code: unknown }).code === KRONOS_DCB_VIOLATION_SQLSTATE
|
|
46
|
+
)
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Public entry point for @kronos-ts/postgres.
|
|
2
|
+
//
|
|
3
|
+
// Wave-1 only exports the error surface; subsequent waves layer in:
|
|
4
|
+
// Plan 04 — postgres() extension factory + PostgresConfig (./postgres.js),
|
|
5
|
+
// createPostgresEventStore (./postgres-event-store.js)
|
|
6
|
+
// Plan 05 — createPostgresSnapshotStore (./postgres-snapshot-store.js)
|
|
7
|
+
//
|
|
8
|
+
// Adapter implementations are NOT exported from this barrel — users import
|
|
9
|
+
// them via the sub-path exports declared in package.json:
|
|
10
|
+
// import { pgAdapter } from "@kronos-ts/postgres/adapters/pg"
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
AppendConditionError,
|
|
14
|
+
KRONOS_DCB_VIOLATION_SQLSTATE,
|
|
15
|
+
isDcbViolation,
|
|
16
|
+
} from "./errors.js"
|
|
17
|
+
|
|
18
|
+
// Adapter contract types (re-export so users can write
|
|
19
|
+
// `function myFn(adapter: PostgresAdapter)` against the package root).
|
|
20
|
+
// Adapter implementations stay sub-path-only.
|
|
21
|
+
export {
|
|
22
|
+
IsolationLevel,
|
|
23
|
+
type PostgresAdapter,
|
|
24
|
+
type PostgresAdapterTransaction,
|
|
25
|
+
type ListenSubscription,
|
|
26
|
+
type QueryRow,
|
|
27
|
+
} from "./adapter.js"
|
|
28
|
+
|
|
29
|
+
// Engine factory (Plan 04 + extended in Plan 05)
|
|
30
|
+
export {
|
|
31
|
+
createPostgresEventStore,
|
|
32
|
+
type PostgresEventStoreConfig,
|
|
33
|
+
type Serializer,
|
|
34
|
+
type TagResolver,
|
|
35
|
+
} from "./postgres-event-store.js"
|
|
36
|
+
|
|
37
|
+
// Snapshot store factory (Plan 05)
|
|
38
|
+
export {
|
|
39
|
+
createPostgresSnapshotStore,
|
|
40
|
+
type PostgresSnapshotStoreConfig,
|
|
41
|
+
} from "./postgres-snapshot-store.js"
|
|
42
|
+
|
|
43
|
+
// Extension factory (Plan 05)
|
|
44
|
+
export { postgres, type PostgresConfig } from "./postgres.js"
|
|
45
|
+
|
|
46
|
+
// Schema bootstrap + DDL builders — exposed for users who want to run their
|
|
47
|
+
// own migrations (set `postgres({ bootstrap: false })`) or drive the store
|
|
48
|
+
// directly without going through the extension factory.
|
|
49
|
+
export {
|
|
50
|
+
bootstrapSchema,
|
|
51
|
+
buildEventsTableDDL,
|
|
52
|
+
buildEventsIndexesDDL,
|
|
53
|
+
buildSnapshotsTableDDL,
|
|
54
|
+
DEFAULT_TABLE_NAMES,
|
|
55
|
+
type TableNames,
|
|
56
|
+
} from "./schema.js"
|