@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/dist/errors.js ADDED
@@ -0,0 +1,41 @@
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
+ * Thrown when an append condition is violated — optimistic concurrency
15
+ * failure. Structurally mirrors `@kronos-ts/eventsourcing`'s
16
+ * AppendConditionError so callers that catch either get equivalent
17
+ * behaviour, but we ship our own class so that the SQLSTATE-catch
18
+ * boundary lives inside this package's import graph.
19
+ */
20
+ export class AppendConditionError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "AppendConditionError";
24
+ }
25
+ static fromConflictCount(count, afterPosition) {
26
+ return new AppendConditionError(`Append condition violated: ${count} conflicting event(s) ` +
27
+ `found after position ${afterPosition}`);
28
+ }
29
+ }
30
+ /**
31
+ * Adapter-agnostic check: pg and postgres.js both surface SQLSTATE on
32
+ * thrown errors as `.code` (string). Bun.sql follows the same convention.
33
+ * This helper keeps the SQLSTATE constant centralised.
34
+ */
35
+ export function isDcbViolation(err) {
36
+ return (typeof err === "object" &&
37
+ err !== null &&
38
+ "code" in err &&
39
+ err.code === KRONOS_DCB_VIOLATION_SQLSTATE);
40
+ }
41
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,OAAO,CAAA;AAEpD;;;;;;GAMG;AACH,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAA;IACpC,CAAC;IAED,MAAM,CAAC,iBAAiB,CAAC,KAAa,EAAE,aAAqB;QAC3D,OAAO,IAAI,oBAAoB,CAC7B,8BAA8B,KAAK,wBAAwB;YACzD,wBAAwB,aAAa,EAAE,CAC1C,CAAA;IACH,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,GAAY;IACzC,OAAO,CACL,OAAO,GAAG,KAAK,QAAQ;QACvB,GAAG,KAAK,IAAI;QACZ,MAAM,IAAI,GAAG;QACZ,GAAyB,CAAC,IAAI,KAAK,6BAA6B,CAClE,CAAA;AACH,CAAC"}
@@ -0,0 +1,7 @@
1
+ export { AppendConditionError, KRONOS_DCB_VIOLATION_SQLSTATE, isDcbViolation, } from "./errors.js";
2
+ export { IsolationLevel, type PostgresAdapter, type PostgresAdapterTransaction, type ListenSubscription, type QueryRow, } from "./adapter.js";
3
+ export { createPostgresEventStore, type PostgresEventStoreConfig, type Serializer, type TagResolver, } from "./postgres-event-store.js";
4
+ export { createPostgresSnapshotStore, type PostgresSnapshotStoreConfig, } from "./postgres-snapshot-store.js";
5
+ export { postgres, type PostgresConfig } from "./postgres.js";
6
+ export { bootstrapSchema, buildEventsTableDDL, buildEventsIndexesDDL, buildSnapshotsTableDDL, DEFAULT_TABLE_NAMES, type TableNames, } from "./schema.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,OAAO,EACL,oBAAoB,EACpB,6BAA6B,EAC7B,cAAc,GACf,MAAM,aAAa,CAAA;AAKpB,OAAO,EACL,cAAc,EACd,KAAK,eAAe,EACpB,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EACvB,KAAK,QAAQ,GACd,MAAM,cAAc,CAAA;AAGrB,OAAO,EACL,wBAAwB,EACxB,KAAK,wBAAwB,EAC7B,KAAK,UAAU,EACf,KAAK,WAAW,GACjB,MAAM,2BAA2B,CAAA;AAGlC,OAAO,EACL,2BAA2B,EAC3B,KAAK,2BAA2B,GACjC,MAAM,8BAA8B,CAAA;AAGrC,OAAO,EAAE,QAAQ,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAA;AAK7D,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,mBAAmB,EACnB,KAAK,UAAU,GAChB,MAAM,aAAa,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
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
+ export { AppendConditionError, KRONOS_DCB_VIOLATION_SQLSTATE, isDcbViolation, } from "./errors.js";
12
+ // Adapter contract types (re-export so users can write
13
+ // `function myFn(adapter: PostgresAdapter)` against the package root).
14
+ // Adapter implementations stay sub-path-only.
15
+ export { IsolationLevel, } from "./adapter.js";
16
+ // Engine factory (Plan 04 + extended in Plan 05)
17
+ export { createPostgresEventStore, } from "./postgres-event-store.js";
18
+ // Snapshot store factory (Plan 05)
19
+ export { createPostgresSnapshotStore, } from "./postgres-snapshot-store.js";
20
+ // Extension factory (Plan 05)
21
+ export { postgres } from "./postgres.js";
22
+ // Schema bootstrap + DDL builders — exposed for users who want to run their
23
+ // own migrations (set `postgres({ bootstrap: false })`) or drive the store
24
+ // directly without going through the extension factory.
25
+ export { bootstrapSchema, buildEventsTableDDL, buildEventsIndexesDDL, buildSnapshotsTableDDL, DEFAULT_TABLE_NAMES, } from "./schema.js";
26
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,EAAE;AACF,oEAAoE;AACpE,6EAA6E;AAC7E,mEAAmE;AACnE,yEAAyE;AACzE,EAAE;AACF,2EAA2E;AAC3E,0DAA0D;AAC1D,gEAAgE;AAEhE,OAAO,EACL,oBAAoB,EACpB,6BAA6B,EAC7B,cAAc,GACf,MAAM,aAAa,CAAA;AAEpB,uDAAuD;AACvD,uEAAuE;AACvE,8CAA8C;AAC9C,OAAO,EACL,cAAc,GAKf,MAAM,cAAc,CAAA;AAErB,iDAAiD;AACjD,OAAO,EACL,wBAAwB,GAIzB,MAAM,2BAA2B,CAAA;AAElC,mCAAmC;AACnC,OAAO,EACL,2BAA2B,GAE5B,MAAM,8BAA8B,CAAA;AAErC,8BAA8B;AAC9B,OAAO,EAAE,QAAQ,EAAuB,MAAM,eAAe,CAAA;AAE7D,4EAA4E;AAC5E,2EAA2E;AAC3E,wDAAwD;AACxD,OAAO,EACL,eAAe,EACf,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,mBAAmB,GAEpB,MAAM,aAAa,CAAA"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * createPostgresEventStore — full EventStorageEngine + EventBus implementation.
3
+ *
4
+ * Plan 12-04 delivered: source, appendEvents, append.
5
+ * Plan 12-05 adds: open (gap-free tailing via xid8 + pg_snapshot_xmin),
6
+ * getHeadPosition, firstToken, latestToken, publish, subscribe.
7
+ *
8
+ * Append path:
9
+ * 1. open transaction at READ COMMITTED
10
+ * 2. acquireWriteLocks for the criteria tags (not event types) so that
11
+ * two writers on the SAME criteria tag serialize, while disjoint-tag
12
+ * writers run in parallel
13
+ * 3. Conflict check: SELECT count(*) WHERE sequence_position > marker AND criteria
14
+ * 4. If conflict count > 0 → throw AppendConditionError (code KR001)
15
+ * 5. INSERT events returning sequence_position for the ConsistencyMarker
16
+ * 6. commit() → COMMIT; afterCommit() → marker; rollback() → fire-and-forget
17
+ * ROLLBACK (synchronous void per the framework contract)
18
+ *
19
+ * Streaming path (open):
20
+ * - Watermark query: (transaction_id, sequence_position) > ($xid, $pos)
21
+ * AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
22
+ * - Wake-up via LISTEN/NOTIFY on `kronos_events_${tables.events}` channel
23
+ * - Fallback to 250ms polling if LISTEN is not supported
24
+ *
25
+ * Note on the stored procedure (buildAppendStoredProcedureDDL): The SP is
26
+ * registered in schema.ts and available on the DB, but this plan uses
27
+ * direct parameterised SQL for the conflict check + INSERT rather than
28
+ * calling the SP. The SP's dynamic-SQL approach has complex $N-rebinding
29
+ * requirements (criteria_params JSONB → USING binding) that are cleaner to
30
+ * handle in TypeScript. Plan 06's review may revisit whether the SP
31
+ * provides a meaningful benefit.
32
+ */
33
+ import type { EventStore } from "@kronos-ts/eventsourcing";
34
+ import type { EventMessage } from "@kronos-ts/messaging";
35
+ import type { Serializer } from "@kronos-ts/common";
36
+ export type { Serializer } from "@kronos-ts/common";
37
+ import type { PostgresAdapter } from "./adapter.js";
38
+ import { type TableNames } from "./schema.js";
39
+ export interface TagResolver {
40
+ resolve(event: EventMessage): ReadonlyArray<{
41
+ key: string;
42
+ value: string;
43
+ }>;
44
+ }
45
+ export interface PostgresEventStoreConfig {
46
+ readonly adapter: PostgresAdapter;
47
+ readonly serializer: Serializer;
48
+ readonly tagResolver: TagResolver;
49
+ readonly tableNames?: TableNames;
50
+ }
51
+ export declare function createPostgresEventStore(config: PostgresEventStoreConfig): EventStore;
52
+ //# sourceMappingURL=postgres-event-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres-event-store.d.ts","sourceRoot":"","sources":["../src/postgres-event-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAGV,UAAU,EACX,MAAM,0BAA0B,CAAA;AAMjC,OAAO,KAAK,EACV,YAAY,EAMb,MAAM,sBAAsB,CAAA;AAG7B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,KAAK,EAAE,eAAe,EAA8B,MAAM,cAAc,CAAA;AAK/E,OAAO,EAAE,KAAK,UAAU,EAAuB,MAAM,aAAa,CAAA;AAIlE,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,KAAK,EAAE,YAAY,GAAG,aAAa,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAC5E;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAA;IACjC,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAA;IAC/B,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;IACjC,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAA;CACjC;AAED,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,wBAAwB,GAC/B,UAAU,CA+dZ"}
@@ -0,0 +1,496 @@
1
+ /**
2
+ * createPostgresEventStore — full EventStorageEngine + EventBus implementation.
3
+ *
4
+ * Plan 12-04 delivered: source, appendEvents, append.
5
+ * Plan 12-05 adds: open (gap-free tailing via xid8 + pg_snapshot_xmin),
6
+ * getHeadPosition, firstToken, latestToken, publish, subscribe.
7
+ *
8
+ * Append path:
9
+ * 1. open transaction at READ COMMITTED
10
+ * 2. acquireWriteLocks for the criteria tags (not event types) so that
11
+ * two writers on the SAME criteria tag serialize, while disjoint-tag
12
+ * writers run in parallel
13
+ * 3. Conflict check: SELECT count(*) WHERE sequence_position > marker AND criteria
14
+ * 4. If conflict count > 0 → throw AppendConditionError (code KR001)
15
+ * 5. INSERT events returning sequence_position for the ConsistencyMarker
16
+ * 6. commit() → COMMIT; afterCommit() → marker; rollback() → fire-and-forget
17
+ * ROLLBACK (synchronous void per the framework contract)
18
+ *
19
+ * Streaming path (open):
20
+ * - Watermark query: (transaction_id, sequence_position) > ($xid, $pos)
21
+ * AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
22
+ * - Wake-up via LISTEN/NOTIFY on `kronos_events_${tables.events}` channel
23
+ * - Fallback to 250ms polling if LISTEN is not supported
24
+ *
25
+ * Note on the stored procedure (buildAppendStoredProcedureDDL): The SP is
26
+ * registered in schema.ts and available on the DB, but this plan uses
27
+ * direct parameterised SQL for the conflict check + INSERT rather than
28
+ * calling the SP. The SP's dynamic-SQL approach has complex $N-rebinding
29
+ * requirements (criteria_params JSONB → USING binding) that are cleaner to
30
+ * handle in TypeScript. Plan 06's review may revisit whether the SP
31
+ * provides a meaningful benefit.
32
+ */
33
+ import { markerAt } from "@kronos-ts/eventsourcing";
34
+ import { createMessageStream, globalSequenceToken, FIRST_TOKEN } from "@kronos-ts/messaging";
35
+ import { qualifiedNameToString, qualifiedNameFromString } from "@kronos-ts/common";
36
+ import { IsolationLevel } from "./adapter.js";
37
+ import { acquireWriteLocks } from "./advisory-locks.js";
38
+ import { buildCriteriaWhere, encodeTag } from "./criteria-sql.js";
39
+ import { AppendConditionError, isDcbViolation, KRONOS_DCB_VIOLATION_SQLSTATE } from "./errors.js";
40
+ import { DEFAULT_TABLE_NAMES } from "./schema.js";
41
+ export function createPostgresEventStore(config) {
42
+ const { adapter, tagResolver } = config;
43
+ const tables = config.tableNames ?? DEFAULT_TABLE_NAMES;
44
+ // Push-based subscriber registry (EventBus.subscribe contract)
45
+ const eventSubscribers = new Set();
46
+ // LISTEN/NOTIFY channel name for wake-up of tailing streams (D-12.14)
47
+ const notifyChannel = `kronos_events_${tables.events}`;
48
+ function eventTypeOf(e) {
49
+ return qualifiedNameToString(e.name);
50
+ }
51
+ /**
52
+ * Extract lock targets from the criteria — the writer locks on what it is
53
+ * READING (the criteria tags), not just what it is writing. This ensures
54
+ * two writers on the same criteria tag serialize (one blocks until the
55
+ * other commits), while writers on disjoint criteria tags run in parallel.
56
+ *
57
+ * For `any-tag` or empty criteria, returns an empty array so only the
58
+ * global-intent S-lock is acquired (acquireWriteLocks handles the empty case).
59
+ */
60
+ function lockTargetsForCondition(condition) {
61
+ if (!condition)
62
+ return [];
63
+ return extractCriteriaTags(condition.criteria).map((tag) => ({ type: "", tag }));
64
+ }
65
+ function extractCriteriaTags(criteria) {
66
+ switch (criteria.kind) {
67
+ case "tags":
68
+ return criteria.tags.map((t) => encodeTag(t.key, t.value));
69
+ case "any-tag":
70
+ // any-tag covers all tags — use global-intent only (empty list)
71
+ return [];
72
+ case "type-restricted":
73
+ return extractCriteriaTags(criteria.inner);
74
+ case "either":
75
+ return criteria.criteria.flatMap((c) => extractCriteriaTags(c));
76
+ }
77
+ }
78
+ function encodedTagsOf(e) {
79
+ return tagResolver.resolve(e).map((t) => encodeTag(t.key, t.value));
80
+ }
81
+ /**
82
+ * Check for DCB conflict and INSERT events, within the caller's transaction.
83
+ * Returns the (position, xid) of the last inserted row.
84
+ *
85
+ * Uses parameterised SQL (no dynamic SQL), so each query is prepared once
86
+ * and has no $N rebinding complexity.
87
+ */
88
+ async function checkAndInsert(tx, events, condition) {
89
+ // --- Conflict check ---
90
+ if (condition) {
91
+ const markerPos = condition.marker.position;
92
+ const built = buildCriteriaWhere(condition.criteria, 2); // $1 = markerPos
93
+ const sql = `SELECT count(*)::bigint AS cnt FROM ${tables.events}
94
+ WHERE sequence_position > $1 AND (${built.where})`;
95
+ const rows = await tx.query(sql, [markerPos, ...built.params]);
96
+ const cnt = BigInt(rows[0]?.cnt ?? 0);
97
+ if (cnt > 0n) {
98
+ // Throw with the KR001 code so isDcbViolation() can identify it
99
+ const err = new AppendConditionError(`Append condition violated: ${cnt} conflicting event(s) after position ${markerPos}`);
100
+ err.code = KRONOS_DCB_VIOLATION_SQLSTATE;
101
+ throw err;
102
+ }
103
+ }
104
+ // --- Insert events ---
105
+ let lastPosition = -1n;
106
+ let lastXid = "";
107
+ for (const e of events) {
108
+ const encodedTags = encodedTagsOf(e);
109
+ const type = eventTypeOf(e);
110
+ const payload = e.payload ?? {};
111
+ const metadata = e.metadata ?? {};
112
+ const rows = await tx.query(`INSERT INTO ${tables.events} (event_id, type, tags, payload, metadata)
113
+ VALUES ($1, $2, $3, $4, $5)
114
+ RETURNING sequence_position, transaction_id`, [
115
+ e.identifier,
116
+ type,
117
+ encodedTags,
118
+ JSON.stringify(payload),
119
+ JSON.stringify(metadata),
120
+ ]);
121
+ const row = rows[0];
122
+ if (!row)
123
+ throw new Error("INSERT returned no rows");
124
+ lastPosition = BigInt(row.sequence_position);
125
+ lastXid = row.transaction_id;
126
+ }
127
+ if (lastPosition < 0n) {
128
+ throw new Error("no events were inserted");
129
+ }
130
+ return { position: lastPosition, xid: lastXid };
131
+ }
132
+ return {
133
+ async source(condition) {
134
+ const start = condition.start ?? 0n;
135
+ const built = buildCriteriaWhere(condition.criteria, 2); // $1 = start
136
+ const sql = `
137
+ SELECT sequence_position, type, tags, payload, metadata
138
+ FROM ${tables.events}
139
+ WHERE sequence_position >= $1 AND (${built.where})
140
+ ORDER BY sequence_position ASC
141
+ `;
142
+ const rows = await adapter.query(sql, [start, ...built.params]);
143
+ const events = rows.map((r) => decodeEvent(r));
144
+ const headRow = await adapter.queryOne(`SELECT MAX(sequence_position)::text AS head FROM ${tables.events}`);
145
+ const head = headRow?.head ? BigInt(headRow.head) : -1n;
146
+ const lastPos = rows.length > 0 ? BigInt(rows[rows.length - 1].sequence_position) : -1n;
147
+ const marker = rows.length > 0 ? markerAt(lastPos) : markerAt(head);
148
+ return { events, marker };
149
+ },
150
+ async appendEvents(events, condition) {
151
+ // Two-phase: open tx, acquire locks, run conflict check + INSERT, hold
152
+ // tx open until commit(). We bridge adapter.transaction() (which owns
153
+ // the full lifecycle) with a deferred that the AppendTransaction controls.
154
+ const targets = lockTargetsForCondition(condition);
155
+ let resolveOuter;
156
+ let rejectOuter;
157
+ const outer = new Promise((res, rej) => {
158
+ resolveOuter = res;
159
+ rejectOuter = rej;
160
+ });
161
+ let txReady;
162
+ const txStaged = new Promise((res) => {
163
+ txReady = res;
164
+ });
165
+ let resolveTxControl;
166
+ const txControl = new Promise((res) => {
167
+ resolveTxControl = res;
168
+ });
169
+ // Kick off the transaction in the background.
170
+ const txPromise = adapter
171
+ .transaction(IsolationLevel.READ_COMMITTED, async (tx) => {
172
+ await acquireWriteLocks(tx, targets);
173
+ let captured;
174
+ try {
175
+ captured = await checkAndInsert(tx, events, condition);
176
+ }
177
+ catch (err) {
178
+ if (isDcbViolation(err)) {
179
+ // Already an AppendConditionError with KR001 code
180
+ throw err;
181
+ }
182
+ if (err.code === "23505") {
183
+ throw AppendConditionError.fromConflictCount(0, condition?.marker.position ?? -1n);
184
+ }
185
+ throw err;
186
+ }
187
+ txReady();
188
+ const cmd = await txControl;
189
+ if (cmd === "rollback") {
190
+ throw new Error("__kronos_rollback__");
191
+ }
192
+ return captured;
193
+ })
194
+ .then((v) => resolveOuter(v), (e) => {
195
+ if (e instanceof Error && e.message === "__kronos_rollback__") {
196
+ rejectOuter(new Error("rolled back"));
197
+ }
198
+ else {
199
+ rejectOuter(e);
200
+ }
201
+ });
202
+ void txPromise;
203
+ // Wait until the SP has executed so that errors (DCB violation) surface
204
+ // BEFORE the AppendTransaction handle is returned.
205
+ await Promise.race([
206
+ txStaged,
207
+ outer.catch(() => {
208
+ return;
209
+ }),
210
+ ]);
211
+ // Surface any already-rejected error immediately.
212
+ let alreadyFailed = false;
213
+ outer.catch(() => {
214
+ alreadyFailed = true;
215
+ });
216
+ await Promise.resolve();
217
+ if (alreadyFailed) {
218
+ await outer;
219
+ }
220
+ let committed = false;
221
+ const transaction = {
222
+ async commit() {
223
+ committed = true;
224
+ resolveTxControl("commit");
225
+ await outer;
226
+ // Wake up tailing streams + notify push-based subscribers after commit
227
+ await adapter.query(`NOTIFY ${notifyChannel}`);
228
+ for (const sub of eventSubscribers) {
229
+ try {
230
+ await sub(events);
231
+ }
232
+ catch { /* ignore subscriber errors */ }
233
+ }
234
+ },
235
+ async afterCommit() {
236
+ const result = await outer;
237
+ return markerAt(result.position);
238
+ },
239
+ rollback() {
240
+ if (committed)
241
+ return;
242
+ resolveTxControl("rollback");
243
+ outer.catch(() => {
244
+ /* swallow — rollback path is expected to reject outer */
245
+ });
246
+ },
247
+ };
248
+ return transaction;
249
+ },
250
+ async append(events, condition) {
251
+ // Convenience: appendEvents + commit + afterCommit in one shot.
252
+ const targets = lockTargetsForCondition(condition);
253
+ let marker;
254
+ try {
255
+ marker = await adapter.transaction(IsolationLevel.READ_COMMITTED, async (tx) => {
256
+ await acquireWriteLocks(tx, targets);
257
+ const captured = await checkAndInsert(tx, events, condition);
258
+ return markerAt(captured.position);
259
+ });
260
+ }
261
+ catch (err) {
262
+ if (isDcbViolation(err)) {
263
+ // Re-throw AppendConditionError directly (already has correct type)
264
+ throw err;
265
+ }
266
+ if (err.code === "23505") {
267
+ throw AppendConditionError.fromConflictCount(0, condition?.marker.position ?? -1n);
268
+ }
269
+ throw err;
270
+ }
271
+ // Wake up tailing streams + notify push-based subscribers
272
+ await adapter.query(`NOTIFY ${notifyChannel}`);
273
+ for (const sub of eventSubscribers) {
274
+ try {
275
+ await sub(events);
276
+ }
277
+ catch { /* ignore subscriber errors */ }
278
+ }
279
+ return marker;
280
+ },
281
+ async getHeadPosition() {
282
+ const row = await adapter.queryOne(`SELECT COALESCE(MAX(sequence_position), 0)::text AS head FROM ${tables.events}`);
283
+ return row?.head ? BigInt(row.head) : 0n;
284
+ },
285
+ async firstToken() {
286
+ return FIRST_TOKEN;
287
+ },
288
+ async latestToken() {
289
+ const row = await adapter.queryOne(`SELECT COALESCE(MAX(sequence_position), 0)::text AS head FROM ${tables.events}`);
290
+ const head = row?.head ? BigInt(row.head) : 0n;
291
+ return globalSequenceToken(head);
292
+ },
293
+ async publish(events) {
294
+ // publish = append without condition; also notifies subscribers + streams
295
+ const targets = [];
296
+ let marker;
297
+ try {
298
+ marker = await adapter.transaction(IsolationLevel.READ_COMMITTED, async (tx) => {
299
+ await acquireWriteLocks(tx, targets);
300
+ const captured = await checkAndInsert(tx, events, undefined);
301
+ return markerAt(captured.position);
302
+ });
303
+ }
304
+ catch (err) {
305
+ if (err.code === "23505") {
306
+ throw AppendConditionError.fromConflictCount(0, -1n);
307
+ }
308
+ throw err;
309
+ }
310
+ void marker;
311
+ await adapter.query(`NOTIFY ${notifyChannel}`);
312
+ for (const sub of eventSubscribers) {
313
+ try {
314
+ await sub(events);
315
+ }
316
+ catch { /* ignore subscriber errors */ }
317
+ }
318
+ },
319
+ subscribe(handler) {
320
+ eventSubscribers.add(handler);
321
+ return () => {
322
+ eventSubscribers.delete(handler);
323
+ };
324
+ },
325
+ open(condition) {
326
+ let cursorPosition = condition.position;
327
+ // The (xid8, position) tuple bookmark. We start with xid8 = '0' which is
328
+ // less than any real xid8 — so the (xid8, position) > ($1, $2) predicate
329
+ // collapses to effectively position > $2 on first read.
330
+ let cursorXid = "0";
331
+ const criteria = condition.criteria;
332
+ let closed = false;
333
+ let onAvailable = null;
334
+ const buffer = [];
335
+ let polling = false;
336
+ let listenSub;
337
+ async function fetchBatch(limit = 100) {
338
+ if (closed)
339
+ return;
340
+ // When we have a real xid cursor, use the (xid8, position) tuple comparison
341
+ // for gap-free ordering. On initial fetch (cursorXid = "0") we don't yet
342
+ // have a real xid, so fall back to a plain sequence_position > $cursor filter —
343
+ // the pg_snapshot_xmin watermark still applies to exclude in-flight transactions.
344
+ let sql;
345
+ let queryParams;
346
+ if (cursorXid === "0") {
347
+ // Initial fetch: simple position filter — all committed events after cursorPosition.
348
+ // $1 = position, criteria starts at $2
349
+ const builtInitial = criteria
350
+ ? buildCriteriaWhere(criteria, 2)
351
+ : { where: "true", params: [], nextParamIndex: 2 };
352
+ const limitParam = builtInitial.nextParamIndex;
353
+ sql = `
354
+ SELECT sequence_position::text AS sequence_position,
355
+ transaction_id::text AS transaction_id,
356
+ type, tags, payload, metadata
357
+ FROM ${tables.events}
358
+ WHERE sequence_position > $1::bigint
359
+ AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
360
+ AND (${builtInitial.where})
361
+ ORDER BY transaction_id ASC, sequence_position ASC
362
+ LIMIT $${limitParam}
363
+ `;
364
+ queryParams = [String(cursorPosition), ...builtInitial.params, limit];
365
+ }
366
+ else {
367
+ // Subsequent fetch: (xid8, position) tuple comparison for gap-free ordering.
368
+ // $1 = xid, $2 = position, criteria starts at $3
369
+ const builtTuple = criteria
370
+ ? buildCriteriaWhere(criteria, 3)
371
+ : { where: "true", params: [], nextParamIndex: 3 };
372
+ const limitParam = builtTuple.nextParamIndex;
373
+ sql = `
374
+ SELECT sequence_position::text AS sequence_position,
375
+ transaction_id::text AS transaction_id,
376
+ type, tags, payload, metadata
377
+ FROM ${tables.events}
378
+ WHERE (transaction_id, sequence_position) > ($1::xid8, $2::bigint)
379
+ AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
380
+ AND (${builtTuple.where})
381
+ ORDER BY transaction_id ASC, sequence_position ASC
382
+ LIMIT $${limitParam}
383
+ `;
384
+ queryParams = [cursorXid, String(cursorPosition), ...builtTuple.params, limit];
385
+ }
386
+ const rows = await adapter.query(sql, queryParams);
387
+ for (const r of rows) {
388
+ const event = decodeEvent(r);
389
+ const seq = BigInt(r.sequence_position);
390
+ buffer.push({ sequence: seq, event });
391
+ cursorXid = r.transaction_id;
392
+ cursorPosition = seq;
393
+ }
394
+ }
395
+ async function pump() {
396
+ if (polling || closed)
397
+ return;
398
+ polling = true;
399
+ try {
400
+ await fetchBatch();
401
+ if (buffer.length > 0 && onAvailable)
402
+ onAvailable();
403
+ }
404
+ finally {
405
+ polling = false;
406
+ }
407
+ }
408
+ // Start polling immediately so we don't miss events that were committed
409
+ // before the LISTEN subscription is established. The poll interval is
410
+ // replaced by NOTIFY-driven pumps once LISTEN is up.
411
+ let pollInterval = setInterval(() => {
412
+ if (closed) {
413
+ clearInterval(pollInterval);
414
+ pollInterval = undefined;
415
+ return;
416
+ }
417
+ void pump();
418
+ }, 250);
419
+ // Wake-up via LISTEN/NOTIFY — supplements polling with instant delivery
420
+ void adapter
421
+ .listen(notifyChannel, () => {
422
+ void pump();
423
+ })
424
+ .then((sub) => {
425
+ listenSub = sub;
426
+ // Keep polling as a safety net even with LISTEN active.
427
+ // The 250ms interval is cheap (no-op when no new events).
428
+ })
429
+ .catch(() => {
430
+ // LISTEN not supported — polling fallback already running above
431
+ });
432
+ // Also run an immediate fetch to pick up any pre-existing events
433
+ void pump();
434
+ return createMessageStream({
435
+ next() {
436
+ return buffer.shift();
437
+ },
438
+ peek() {
439
+ return buffer[0];
440
+ },
441
+ hasNextAvailable() {
442
+ return buffer.length > 0;
443
+ },
444
+ setCallback(cb) {
445
+ onAvailable = cb;
446
+ },
447
+ isCompleted() {
448
+ return closed;
449
+ },
450
+ error() {
451
+ return undefined;
452
+ },
453
+ close() {
454
+ closed = true;
455
+ onAvailable = null;
456
+ if (pollInterval) {
457
+ clearInterval(pollInterval);
458
+ pollInterval = undefined;
459
+ }
460
+ if (listenSub)
461
+ void listenSub.unlisten();
462
+ },
463
+ });
464
+ },
465
+ };
466
+ }
467
+ function decodeEvent(row) {
468
+ const qn = qualifiedNameFromString(row.type);
469
+ const tags = row.tags.map((t) => {
470
+ const sep = t.indexOf("");
471
+ return sep >= 0
472
+ ? { key: t.slice(0, sep), value: t.slice(sep + 1) }
473
+ : { key: t, value: "" };
474
+ });
475
+ return {
476
+ name: qn,
477
+ tags,
478
+ payload: decodeJsonb(row.payload),
479
+ metadata: decodeJsonb(row.metadata),
480
+ };
481
+ }
482
+ // Adapter-agnostic JSONB decoding: pgAdapter/postgresAdapter return parsed
483
+ // objects, but bunSqlAdapter (Bun.SQL) returns JSONB as a raw string. Normalise
484
+ // here so callers always see a JS object.
485
+ function decodeJsonb(v) {
486
+ if (typeof v === "string") {
487
+ try {
488
+ return JSON.parse(v);
489
+ }
490
+ catch {
491
+ return v;
492
+ }
493
+ }
494
+ return v;
495
+ }
496
+ //# sourceMappingURL=postgres-event-store.js.map