@kronos-ts/postgres 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +176 -0
  2. package/dist/adapter.d.ts +89 -0
  3. package/dist/adapter.d.ts.map +1 -0
  4. package/dist/adapter.js +29 -0
  5. package/dist/adapter.js.map +1 -0
  6. package/dist/adapters/bun-sql.d.ts +23 -0
  7. package/dist/adapters/bun-sql.d.ts.map +1 -0
  8. package/dist/adapters/bun-sql.js +175 -0
  9. package/dist/adapters/bun-sql.js.map +1 -0
  10. package/dist/adapters/pg.d.ts +24 -0
  11. package/dist/adapters/pg.d.ts.map +1 -0
  12. package/dist/adapters/pg.js +156 -0
  13. package/dist/adapters/pg.js.map +1 -0
  14. package/dist/adapters/postgres.d.ts +27 -0
  15. package/dist/adapters/postgres.d.ts.map +1 -0
  16. package/dist/adapters/postgres.js +99 -0
  17. package/dist/adapters/postgres.js.map +1 -0
  18. package/dist/advisory-locks.d.ts +56 -0
  19. package/dist/advisory-locks.d.ts.map +1 -0
  20. package/dist/advisory-locks.js +112 -0
  21. package/dist/advisory-locks.js.map +1 -0
  22. package/dist/criteria-sql.d.ts +29 -0
  23. package/dist/criteria-sql.d.ts.map +1 -0
  24. package/dist/criteria-sql.js +69 -0
  25. package/dist/criteria-sql.js.map +1 -0
  26. package/dist/errors.d.ts +30 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +41 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +26 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/postgres-event-store.d.ts +52 -0
  35. package/dist/postgres-event-store.d.ts.map +1 -0
  36. package/dist/postgres-event-store.js +496 -0
  37. package/dist/postgres-event-store.js.map +1 -0
  38. package/dist/postgres-snapshot-store.d.ts +34 -0
  39. package/dist/postgres-snapshot-store.d.ts.map +1 -0
  40. package/dist/postgres-snapshot-store.js +122 -0
  41. package/dist/postgres-snapshot-store.js.map +1 -0
  42. package/dist/postgres.d.ts +34 -0
  43. package/dist/postgres.d.ts.map +1 -0
  44. package/dist/postgres.js +42 -0
  45. package/dist/postgres.js.map +1 -0
  46. package/dist/schema.d.ts +96 -0
  47. package/dist/schema.d.ts.map +1 -0
  48. package/dist/schema.js +174 -0
  49. package/dist/schema.js.map +1 -0
  50. package/package.json +93 -0
  51. package/src/adapter.ts +104 -0
  52. package/src/adapters/bun-sql.ts +228 -0
  53. package/src/adapters/pg.ts +189 -0
  54. package/src/adapters/postgres.ts +134 -0
  55. package/src/advisory-locks.ts +139 -0
  56. package/src/criteria-sql.ts +89 -0
  57. package/src/errors.ts +47 -0
  58. package/src/index.ts +56 -0
  59. package/src/postgres-event-store.ts +593 -0
  60. package/src/postgres-snapshot-store.ts +153 -0
  61. package/src/postgres.ts +66 -0
  62. package/src/schema.ts +204 -0
@@ -0,0 +1,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"