@kronos-ts/postgres 0.1.1 → 0.3.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 (46) hide show
  1. package/README.md +81 -0
  2. package/dist/adapter.d.ts +13 -0
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapters/bun-sql.d.ts.map +1 -1
  5. package/dist/adapters/bun-sql.js +3 -0
  6. package/dist/adapters/bun-sql.js.map +1 -1
  7. package/dist/adapters/pg.d.ts.map +1 -1
  8. package/dist/adapters/pg.js +3 -0
  9. package/dist/adapters/pg.js.map +1 -1
  10. package/dist/adapters/postgres.d.ts.map +1 -1
  11. package/dist/adapters/postgres.js +3 -0
  12. package/dist/adapters/postgres.js.map +1 -1
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +11 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/postgres-event-scheduler.d.ts +88 -0
  18. package/dist/postgres-event-scheduler.d.ts.map +1 -0
  19. package/dist/postgres-event-scheduler.js +226 -0
  20. package/dist/postgres-event-scheduler.js.map +1 -0
  21. package/dist/postgres-event-store.d.ts.map +1 -1
  22. package/dist/postgres-event-store.js +70 -35
  23. package/dist/postgres-event-store.js.map +1 -1
  24. package/dist/postgres-transaction-manager.d.ts +33 -0
  25. package/dist/postgres-transaction-manager.d.ts.map +1 -0
  26. package/dist/postgres-transaction-manager.js +92 -0
  27. package/dist/postgres-transaction-manager.js.map +1 -0
  28. package/dist/postgres.d.ts +22 -5
  29. package/dist/postgres.d.ts.map +1 -1
  30. package/dist/postgres.js +54 -5
  31. package/dist/postgres.js.map +1 -1
  32. package/dist/schema.d.ts +47 -0
  33. package/dist/schema.d.ts.map +1 -1
  34. package/dist/schema.js +67 -0
  35. package/dist/schema.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/adapter.ts +13 -0
  38. package/src/adapters/bun-sql.ts +3 -0
  39. package/src/adapters/pg.ts +3 -0
  40. package/src/adapters/postgres.ts +3 -0
  41. package/src/index.ts +18 -0
  42. package/src/postgres-event-scheduler.ts +314 -0
  43. package/src/postgres-event-store.ts +77 -30
  44. package/src/postgres-transaction-manager.ts +120 -0
  45. package/src/postgres.ts +68 -5
  46. package/src/schema.ts +70 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * postgresTransactionManager — bridges the framework's TransactionManager
3
+ * lifecycle to the postgres adapter's callback-shaped `adapter.transaction`.
4
+ *
5
+ * adapter.transaction(IL, fn) opens a pg tx, runs `fn(tx)`, and COMMITs on
6
+ * fn-resolve / ROLLBACKs on fn-reject. The framework needs a tx whose
7
+ * commit/rollback is callable LATER (at the UoW's COMMIT or onError phase),
8
+ * not when fn returns. We bridge by parking `fn` on a deferred completion
9
+ * promise — `commit()` resolves it (→ adapter commits), `rollback()`
10
+ * rejects it (→ adapter rolls back).
11
+ *
12
+ * Same shape as kysely/prisma transaction managers in this repo.
13
+ */
14
+
15
+ import { getOrBeginActiveTransaction, type TransactionManager } from "@kronos-ts/messaging"
16
+ import type { PostgresAdapter, PostgresAdapterTransaction } from "./adapter.js"
17
+ import { IsolationLevel } from "./adapter.js"
18
+
19
+ /**
20
+ * Module-private symbol attaching commit/rollback control to a tx handle.
21
+ * Consumers via `getActiveTransaction<PostgresAdapterTransaction>()` see
22
+ * only `{ query }` — they cannot read this without importing the symbol.
23
+ */
24
+ const TX_CONTROL = Symbol("kronos.postgresTxControl")
25
+
26
+ interface TxControl {
27
+ readonly resolveCommit: () => void
28
+ readonly rejectRollback: (err: unknown) => void
29
+ readonly txPromise: Promise<void>
30
+ }
31
+
32
+ interface ManagedPostgresTransaction extends PostgresAdapterTransaction {
33
+ [TX_CONTROL]: TxControl
34
+ }
35
+
36
+ /** Marker error: signals an intentional rollback so the .catch can suppress it. */
37
+ const ROLLBACK_MARKER = "__kronos_postgres_tx_rollback__"
38
+
39
+ export function postgresTransactionManager(
40
+ adapter: PostgresAdapter,
41
+ isolationLevel: IsolationLevel = IsolationLevel.READ_COMMITTED,
42
+ ): TransactionManager<PostgresAdapterTransaction> {
43
+ return {
44
+ async begin(): Promise<PostgresAdapterTransaction> {
45
+ let captureTx!: (tx: PostgresAdapterTransaction) => void
46
+ const txReady = new Promise<PostgresAdapterTransaction>((res) => {
47
+ captureTx = res
48
+ })
49
+
50
+ let resolveCommit!: () => void
51
+ let rejectRollback!: (err: unknown) => void
52
+ const completion = new Promise<void>((res, rej) => {
53
+ resolveCommit = res
54
+ rejectRollback = rej
55
+ })
56
+
57
+ const txPromise = adapter
58
+ .transaction(isolationLevel, async (tx) => {
59
+ captureTx(tx)
60
+ await completion
61
+ })
62
+ .then(
63
+ () => undefined,
64
+ (err) => {
65
+ // Suppress the marker — rollback is an expected outcome.
66
+ if (err instanceof Error && err.message === ROLLBACK_MARKER) return
67
+ throw err
68
+ },
69
+ )
70
+
71
+ const tx = (await txReady) as ManagedPostgresTransaction
72
+ tx[TX_CONTROL] = { resolveCommit, rejectRollback, txPromise }
73
+ return tx
74
+ },
75
+
76
+ async commit(tx: PostgresAdapterTransaction): Promise<void> {
77
+ const ctrl = (tx as ManagedPostgresTransaction)[TX_CONTROL]
78
+ ctrl.resolveCommit()
79
+ await ctrl.txPromise
80
+ },
81
+
82
+ async rollback(tx: PostgresAdapterTransaction): Promise<void> {
83
+ const ctrl = (tx as ManagedPostgresTransaction)[TX_CONTROL]
84
+ ctrl.rejectRollback(new Error(ROLLBACK_MARKER))
85
+ try {
86
+ await ctrl.txPromise
87
+ } catch (err) {
88
+ // A real follow-up error during ROLLBACK execution. Don't throw
89
+ // from rollback() — the UoW is already in an error path and a
90
+ // cascading throw masks the original failure.
91
+ console.warn("postgresTransactionManager: rollback path threw:", err)
92
+ }
93
+ },
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Run `fn` inside a postgres tx, joining a UoW-scoped tx if one is active
99
+ * (or installed lazily), otherwise opening an ad-hoc tx via `adapter.transaction`.
100
+ *
101
+ * This is what every postgres-extension write path should funnel through —
102
+ * event store appends, snapshot writes, scheduler inserts — so that all
103
+ * writes inside a single UoW land in one pg tx and commit/roll back atomically.
104
+ * Calls from outside any UoW (e.g., the scheduler worker loop, projection
105
+ * queries, lifecycle bootstraps) get their own short-lived tx.
106
+ *
107
+ * Returns whatever `fn` returns. Tx commit/rollback happens when the
108
+ * surrounding UoW's COMMIT/onError fires (joined path) or when `fn` resolves/
109
+ * rejects (ad-hoc path).
110
+ */
111
+ export async function withSharedOrOwnTx<R>(
112
+ adapter: PostgresAdapter,
113
+ fn: (tx: PostgresAdapterTransaction) => Promise<R>,
114
+ isolationLevel: IsolationLevel = IsolationLevel.READ_COMMITTED,
115
+ ): Promise<R> {
116
+ const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
117
+ if (shared !== undefined) return fn(shared)
118
+ return adapter.transaction(isolationLevel, fn)
119
+ }
120
+
package/src/postgres.ts CHANGED
@@ -1,9 +1,21 @@
1
1
  /**
2
2
  * postgres(config) — Extension factory for @kronos-ts/postgres.
3
3
  *
4
- * Populates two slots (D-12.01):
5
- * - eventStore : EventStorageEngine via createPostgresEventStore
6
- * - snapshotStore : SnapshotStore via createPostgresSnapshotStore
4
+ * Populates five slots:
5
+ * - eventStore : EventStorageEngine via createPostgresEventStore
6
+ * - snapshotStore : SnapshotStore via createPostgresSnapshotStore
7
+ * - transactionManager : postgresTransactionManager(adapter)
8
+ * - unitOfWorkFactory : lazyTransactionalUnitOfWorkFactory(runInNewUoW, tm)
9
+ * - eventScheduler : createPostgresEventScheduler(...) (durable,
10
+ * background worker started in "processors" stage)
11
+ *
12
+ * Setting the last two together is what gives `append() + schedule()` (and
13
+ * any future postgres-extension writer) a SHARED UoW transaction —
14
+ * everything that writes inside one UoW commits or rolls back atomically.
15
+ * Lazy variant chosen so pure-read UoWs (queries, projections that don't
16
+ * write) never claim a pool connection. Users who need different
17
+ * composition (e.g., eager for benchmarking, custom UoW wrapping) can
18
+ * override with `app.forceSet(...)`.
7
19
  *
8
20
  * Lifecycle (mirrors @kronos-ts/kronosdb extension shape):
9
21
  * - app.onStart("connect", ...) — adapter.connect() with withRetry; then
@@ -11,16 +23,25 @@
11
23
  * their own migration tooling are not surprised)
12
24
  * - app.onStop("connect", ...) — adapter.disconnect()
13
25
  *
14
- * Does NOT populate eventBus, commandBus, queryBus, tokenStore, or
15
- * transactionManager (D-12.01out of scope for this extension).
26
+ * Does NOT populate eventBus, commandBus, queryBus, or tokenStore (out of
27
+ * scope for this extension postgres token store is a separate package).
16
28
  */
17
29
 
18
30
  import type { App } from "@kronos-ts/app"
19
31
  import type { ResilienceConfig } from "@kronos-ts/common"
20
32
  import { withRetry } from "@kronos-ts/common"
33
+ import {
34
+ lazyTransactionalUnitOfWorkFactory,
35
+ runInNewUoW,
36
+ } from "@kronos-ts/messaging"
21
37
  import type { PostgresAdapter } from "./adapter.js"
22
38
  import { createPostgresEventStore } from "./postgres-event-store.js"
23
39
  import { createPostgresSnapshotStore } from "./postgres-snapshot-store.js"
40
+ import { postgresTransactionManager } from "./postgres-transaction-manager.js"
41
+ import {
42
+ createPostgresEventScheduler,
43
+ type PostgresEventScheduler,
44
+ } from "./postgres-event-scheduler.js"
24
45
  import { bootstrapSchema, DEFAULT_TABLE_NAMES, type TableNames } from "./schema.js"
25
46
 
26
47
  export interface PostgresConfig {
@@ -34,6 +55,11 @@ export interface PostgresConfig {
34
55
  /** Retry policy for the initial connect + bootstrap. Defaults to
35
56
  * framework defaults via withRetry. */
36
57
  readonly resilience?: Partial<ResilienceConfig>
58
+ /** Tuning for the durable scheduler's polling worker. */
59
+ readonly scheduler?: {
60
+ readonly pollIntervalMs?: number
61
+ readonly batchSize?: number
62
+ }
37
63
  }
38
64
 
39
65
  export function postgres(config: PostgresConfig): (app: App) => void {
@@ -41,6 +67,8 @@ export function postgres(config: PostgresConfig): (app: App) => void {
41
67
  const bootstrap = config.bootstrap ?? true
42
68
  const tables = config.tableNames ?? DEFAULT_TABLE_NAMES
43
69
 
70
+ const txManager = postgresTransactionManager(adapter)
71
+
44
72
  return (app: App) => {
45
73
  app.set("eventStore", ({ serializer, tagResolver }) =>
46
74
  createPostgresEventStore({ adapter, serializer, tagResolver, tableNames: tables }),
@@ -48,6 +76,33 @@ export function postgres(config: PostgresConfig): (app: App) => void {
48
76
  app.set("snapshotStore", ({ serializer }) =>
49
77
  createPostgresSnapshotStore({ adapter, serializer, tableNames: tables }),
50
78
  )
79
+ app.set("transactionManager", () => txManager)
80
+ // Lazy: pure-read UoWs never claim a connection; the first writer (an
81
+ // append flush, or a user's own SQL via getOrBeginActiveTransaction)
82
+ // begins the tx, and everything in that UoW — events AND co-located
83
+ // writes — commits or rolls back together. The command bus runs handlers
84
+ // through this factory (see createSimpleCommandBus), so command handlers
85
+ // get the transaction without this extension reaching into the command
86
+ // pipeline.
87
+ app.set("unitOfWorkFactory", () =>
88
+ lazyTransactionalUnitOfWorkFactory(runInNewUoW, txManager),
89
+ )
90
+
91
+ // Durable scheduler — closure captures the instance so the worker can be
92
+ // start()'d in "processors" and stop()'d in "connect" symmetric to other
93
+ // background workers.
94
+ let scheduler: PostgresEventScheduler | undefined
95
+ app.set("eventScheduler", ({ eventStore, unitOfWorkFactory, tagResolver }) => {
96
+ scheduler = createPostgresEventScheduler({
97
+ adapter,
98
+ eventStore,
99
+ uowFactory: unitOfWorkFactory,
100
+ tagResolver,
101
+ tableNames: tables,
102
+ ...config.scheduler,
103
+ })
104
+ return scheduler
105
+ })
51
106
 
52
107
  app.onStart("connect", async () => {
53
108
  await withRetry(() => adapter.connect(), { event: "initial-connect", ...resilience })
@@ -59,7 +114,15 @@ export function postgres(config: PostgresConfig): (app: App) => void {
59
114
  }
60
115
  })
61
116
 
117
+ // Worker spins up after registration/warmup so all slots are resolved and
118
+ // any user-supplied processors are in place before due schedules start
119
+ // firing into the event store.
120
+ app.onStart("processors", async () => {
121
+ if (scheduler) await scheduler.start()
122
+ })
123
+
62
124
  app.onStop("connect", async () => {
125
+ if (scheduler) await scheduler.stop()
63
126
  await adapter.disconnect()
64
127
  })
65
128
  }
package/src/schema.ts CHANGED
@@ -17,11 +17,13 @@
17
17
  export interface TableNames {
18
18
  readonly events: string
19
19
  readonly snapshots: string
20
+ readonly scheduled: string
20
21
  }
21
22
 
22
23
  export const DEFAULT_TABLE_NAMES: TableNames = {
23
24
  events: "kronos_events",
24
25
  snapshots: "kronos_snapshots",
26
+ scheduled: "kronos_scheduled_events",
25
27
  }
26
28
 
27
29
  /**
@@ -76,6 +78,72 @@ export function buildSnapshotsTableDDL(tables: TableNames): string {
76
78
  );`
77
79
  }
78
80
 
81
+ /**
82
+ * Scheduled-events table — holds events parked for future append.
83
+ *
84
+ * # Row lifecycle (tombstone model)
85
+ *
86
+ * INSERT (status='pending') ← schedule() inside a UoW
87
+ * ├── UPDATE → 'appended' ← worker fires the schedule; row stays as tombstone
88
+ * └── UPDATE → 'cancelled' ← cancel(token) succeeds; row stays as tombstone
89
+ *
90
+ * Tombstones (rather than DELETE-on-fire) give cancel() three distinct
91
+ * outcomes — `cancelled` / `already-appended` / `not-found` — by inspecting
92
+ * the row's terminal status. The events table already grows unboundedly,
93
+ * so a parallel tombstone table is no worse from a retention perspective.
94
+ *
95
+ * # Schedule id = event id
96
+ *
97
+ * `schedule_id` is the same UUID as the eventual `event_id` written to the
98
+ * events table at fire-time. One UUID identifies the schedule pre-fire and
99
+ * the event post-fire, so callers tracking the materialised event can
100
+ * correlate back to the original schedule without an extra column.
101
+ *
102
+ * # Payload columns
103
+ *
104
+ * The whole EventMessage shape is captured inline (event_id, type, tags,
105
+ * payload, metadata, version, message_timestamp) so the fire-time worker
106
+ * can reconstruct it from a single row read. `message_timestamp` is the
107
+ * EventMessage's authored timestamp (epoch ms) — distinct from
108
+ * `created_at` (when the row was inserted) and `fire_at` (when it should
109
+ * fire). At append-time, the worker MAY overwrite message_timestamp with
110
+ * `now()` so consumers see the actual append time; that is an
111
+ * implementation decision left to the scheduler.
112
+ */
113
+ export function buildScheduledEventsTableDDL(tables: TableNames): string {
114
+ return `CREATE TABLE IF NOT EXISTS ${tables.scheduled} (
115
+ schedule_id UUID PRIMARY KEY,
116
+ fire_at TIMESTAMPTZ NOT NULL,
117
+ status TEXT NOT NULL DEFAULT 'pending'
118
+ CHECK (status IN ('pending', 'appended', 'cancelled')),
119
+ type TEXT COLLATE "C" NOT NULL,
120
+ tags TEXT[] NOT NULL DEFAULT '{}',
121
+ payload JSONB NOT NULL,
122
+ metadata JSONB NOT NULL DEFAULT '{}',
123
+ version TEXT NOT NULL,
124
+ message_timestamp BIGINT NOT NULL,
125
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
126
+ );`
127
+ }
128
+
129
+ /**
130
+ * Indexes for the scheduled-events table.
131
+ *
132
+ * The single critical index is the partial btree on `fire_at WHERE status =
133
+ * 'pending'`. The worker's hot query — `SELECT … WHERE status = 'pending'
134
+ * AND fire_at <= now() ORDER BY fire_at LIMIT n FOR UPDATE SKIP LOCKED` —
135
+ * scans only pending rows, so a partial index keeps the hot path B-tree
136
+ * tiny regardless of how many appended/cancelled tombstones accumulate.
137
+ *
138
+ * No index on `status` alone — every status query also filters by either
139
+ * schedule_id (PK lookup) or fire_at (the partial index above).
140
+ */
141
+ export function buildScheduledEventsIndexesDDL(tables: TableNames): string {
142
+ return `CREATE INDEX IF NOT EXISTS ${tables.scheduled}_pending_fire_at_idx
143
+ ON ${tables.scheduled} (fire_at)
144
+ WHERE status = 'pending';`
145
+ }
146
+
79
147
  /**
80
148
  * Minimal adapter contract bootstrapSchema needs. A subset of the full
81
149
  * PostgresAdapter interface authored in Plan 12-03 — structurally
@@ -195,6 +263,8 @@ export async function bootstrapSchema(
195
263
  await adapter.query(buildEventsTableDDL(tables))
196
264
  await adapter.query(buildEventsIndexesDDL(tables))
197
265
  await adapter.query(buildSnapshotsTableDDL(tables))
266
+ await adapter.query(buildScheduledEventsTableDDL(tables))
267
+ await adapter.query(buildScheduledEventsIndexesDDL(tables))
198
268
  await adapter.query(buildAppendStoredProcedureDDL(tables))
199
269
  } finally {
200
270
  // Release even on partial-DDL failure. The error (if any) propagates