@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
package/dist/schema.js CHANGED
@@ -16,6 +16,7 @@
16
16
  export const DEFAULT_TABLE_NAMES = {
17
17
  events: "kronos_events",
18
18
  snapshots: "kronos_snapshots",
19
+ scheduled: "kronos_scheduled_events",
19
20
  };
20
21
  /**
21
22
  * Session-scoped advisory lock key for the schema bootstrap.
@@ -65,6 +66,70 @@ export function buildSnapshotsTableDDL(tables) {
65
66
  PRIMARY KEY (state_name, state_id)
66
67
  );`;
67
68
  }
69
+ /**
70
+ * Scheduled-events table — holds events parked for future append.
71
+ *
72
+ * # Row lifecycle (tombstone model)
73
+ *
74
+ * INSERT (status='pending') ← schedule() inside a UoW
75
+ * ├── UPDATE → 'appended' ← worker fires the schedule; row stays as tombstone
76
+ * └── UPDATE → 'cancelled' ← cancel(token) succeeds; row stays as tombstone
77
+ *
78
+ * Tombstones (rather than DELETE-on-fire) give cancel() three distinct
79
+ * outcomes — `cancelled` / `already-appended` / `not-found` — by inspecting
80
+ * the row's terminal status. The events table already grows unboundedly,
81
+ * so a parallel tombstone table is no worse from a retention perspective.
82
+ *
83
+ * # Schedule id = event id
84
+ *
85
+ * `schedule_id` is the same UUID as the eventual `event_id` written to the
86
+ * events table at fire-time. One UUID identifies the schedule pre-fire and
87
+ * the event post-fire, so callers tracking the materialised event can
88
+ * correlate back to the original schedule without an extra column.
89
+ *
90
+ * # Payload columns
91
+ *
92
+ * The whole EventMessage shape is captured inline (event_id, type, tags,
93
+ * payload, metadata, version, message_timestamp) so the fire-time worker
94
+ * can reconstruct it from a single row read. `message_timestamp` is the
95
+ * EventMessage's authored timestamp (epoch ms) — distinct from
96
+ * `created_at` (when the row was inserted) and `fire_at` (when it should
97
+ * fire). At append-time, the worker MAY overwrite message_timestamp with
98
+ * `now()` so consumers see the actual append time; that is an
99
+ * implementation decision left to the scheduler.
100
+ */
101
+ export function buildScheduledEventsTableDDL(tables) {
102
+ return `CREATE TABLE IF NOT EXISTS ${tables.scheduled} (
103
+ schedule_id UUID PRIMARY KEY,
104
+ fire_at TIMESTAMPTZ NOT NULL,
105
+ status TEXT NOT NULL DEFAULT 'pending'
106
+ CHECK (status IN ('pending', 'appended', 'cancelled')),
107
+ type TEXT COLLATE "C" NOT NULL,
108
+ tags TEXT[] NOT NULL DEFAULT '{}',
109
+ payload JSONB NOT NULL,
110
+ metadata JSONB NOT NULL DEFAULT '{}',
111
+ version TEXT NOT NULL,
112
+ message_timestamp BIGINT NOT NULL,
113
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
114
+ );`;
115
+ }
116
+ /**
117
+ * Indexes for the scheduled-events table.
118
+ *
119
+ * The single critical index is the partial btree on `fire_at WHERE status =
120
+ * 'pending'`. The worker's hot query — `SELECT … WHERE status = 'pending'
121
+ * AND fire_at <= now() ORDER BY fire_at LIMIT n FOR UPDATE SKIP LOCKED` —
122
+ * scans only pending rows, so a partial index keeps the hot path B-tree
123
+ * tiny regardless of how many appended/cancelled tombstones accumulate.
124
+ *
125
+ * No index on `status` alone — every status query also filters by either
126
+ * schedule_id (PK lookup) or fire_at (the partial index above).
127
+ */
128
+ export function buildScheduledEventsIndexesDDL(tables) {
129
+ return `CREATE INDEX IF NOT EXISTS ${tables.scheduled}_pending_fire_at_idx
130
+ ON ${tables.scheduled} (fire_at)
131
+ WHERE status = 'pending';`;
132
+ }
68
133
  /**
69
134
  * Append-with-DCB-check stored procedure.
70
135
  *
@@ -163,6 +228,8 @@ export async function bootstrapSchema(adapter, options = {}) {
163
228
  await adapter.query(buildEventsTableDDL(tables));
164
229
  await adapter.query(buildEventsIndexesDDL(tables));
165
230
  await adapter.query(buildSnapshotsTableDDL(tables));
231
+ await adapter.query(buildScheduledEventsTableDDL(tables));
232
+ await adapter.query(buildScheduledEventsIndexesDDL(tables));
166
233
  await adapter.query(buildAppendStoredProcedureDDL(tables));
167
234
  }
168
235
  finally {
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH,MAAM,CAAC,MAAM,mBAAmB,GAAe;IAC7C,MAAM,EAAE,eAAe;IACvB,SAAS,EAAE,kBAAkB;CAC9B,CAAA;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAW,CAAC,MAAM,CAAA;AAErD,MAAM,UAAU,mBAAmB,CAAC,MAAkB;IACpD,mFAAmF;IACnF,+EAA+E;IAC/E,0EAA0E;IAC1E,OAAO,8BAA8B,MAAM,CAAC,MAAM;;;;;;;;;;;;;GAajD,CAAA;AACH,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,MAAkB;IACtD,OAAO,qCAAqC,MAAM,CAAC,MAAM;OACpD,MAAM,CAAC,MAAM;;6BAES,MAAM,CAAC,MAAM;OACnC,MAAM,CAAC,MAAM,2CAA2C,CAAA;AAC/D,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,MAAkB;IACvD,OAAO,8BAA8B,MAAM,CAAC,SAAS;;;;;;;;GAQpD,CAAA;AACH,CAAC;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,UAAU,6BAA6B,CAAC,MAAkB;IAC9D,OAAO;;;;;;;;;;;;;;;;;8BAiBqB,MAAM,CAAC,MAAM;;;;;;;;;;;;;;;;kBAgBzB,MAAM,CAAC,MAAM;;;;;;qBAMV,CAAA;AACrB,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,OAA+B,EAC/B,UAAkC,EAAE;IAEpC,MAAM,MAAM,GAAG,OAAO,CAAC,UAAU,IAAI,mBAAmB,CAAA;IAExD,uEAAuE;IACvE,qEAAqE;IACrE,sBAAsB;IACtB,MAAM,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,CAAC,sBAAsB,CAAC,CAAC,CAAA;IAE5E,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,KAAK,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAA;QAChD,MAAM,OAAO,CAAC,KAAK,CAAC,qBAAqB,CAAC,MAAM,CAAC,CAAC,CAAA;QAClD,MAAM,OAAO,CAAC,KAAK,CAAC,sBAAsB,CAAC,MAAM,CAAC,CAAC,CAAA;QACnD,MAAM,OAAO,CAAC,KAAK,CAAC,6BAA6B,CAAC,MAAM,CAAC,CAAC,CAAA;IAC5D,CAAC;YAAS,CAAC;QACT,qEAAqE;QACrE,+CAA+C;QAC/C,MAAM,OAAO,CAAC,KAAK,CAAC,+BAA+B,EAAE,CAAC,sBAAsB,CAAC,CAAC,CAAA;IAChF,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH,MAAM,CAAC,MAAM,mBAAmB,GAAe;IAC7C,MAAM,EAAE,eAAe;IACvB,SAAS,EAAE,kBAAkB;IAC7B,SAAS,EAAE,yBAAyB;CACrC,CAAA;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAW,CAAC,MAAM,CAAA;AAErD,MAAM,UAAU,mBAAmB,CAAC,MAAkB;IACpD,mFAAmF;IACnF,+EAA+E;IAC/E,0EAA0E;IAC1E,OAAO,8BAA8B,MAAM,CAAC,MAAM;;;;;;;;;;;;;GAajD,CAAA;AACH,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,MAAkB;IACtD,OAAO,qCAAqC,MAAM,CAAC,MAAM;OACpD,MAAM,CAAC,MAAM;;6BAES,MAAM,CAAC,MAAM;OACnC,MAAM,CAAC,MAAM,2CAA2C,CAAA;AAC/D,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,MAAkB;IACvD,OAAO,8BAA8B,MAAM,CAAC,SAAS;;;;;;;;GAQpD,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,UAAU,4BAA4B,CAAC,MAAkB;IAC7D,OAAO,8BAA8B,MAAM,CAAC,SAAS;;;;;;;;;;;;GAYpD,CAAA;AACH,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,8BAA8B,CAAC,MAAkB;IAC/D,OAAO,8BAA8B,MAAM,CAAC,SAAS;OAChD,MAAM,CAAC,SAAS;4BACK,CAAA;AAC5B,CAAC;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,UAAU,6BAA6B,CAAC,MAAkB;IAC9D,OAAO;;;;;;;;;;;;;;;;;8BAiBqB,MAAM,CAAC,MAAM;;;;;;;;;;;;;;;;kBAgBzB,MAAM,CAAC,MAAM;;;;;;qBAMV,CAAA;AACrB,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,OAA+B,EAC/B,UAAkC,EAAE;IAEpC,MAAM,MAAM,GAAG,OAAO,CAAC,UAAU,IAAI,mBAAmB,CAAA;IAExD,uEAAuE;IACvE,qEAAqE;IACrE,sBAAsB;IACtB,MAAM,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,CAAC,sBAAsB,CAAC,CAAC,CAAA;IAE5E,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,KAAK,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAA;QAChD,MAAM,OAAO,CAAC,KAAK,CAAC,qBAAqB,CAAC,MAAM,CAAC,CAAC,CAAA;QAClD,MAAM,OAAO,CAAC,KAAK,CAAC,sBAAsB,CAAC,MAAM,CAAC,CAAC,CAAA;QACnD,MAAM,OAAO,CAAC,KAAK,CAAC,4BAA4B,CAAC,MAAM,CAAC,CAAC,CAAA;QACzD,MAAM,OAAO,CAAC,KAAK,CAAC,8BAA8B,CAAC,MAAM,CAAC,CAAC,CAAA;QAC3D,MAAM,OAAO,CAAC,KAAK,CAAC,6BAA6B,CAAC,MAAM,CAAC,CAAC,CAAA;IAC5D,CAAC;YAAS,CAAC;QACT,qEAAqE;QACrE,+CAA+C;QAC/C,MAAM,OAAO,CAAC,KAAK,CAAC,+BAA+B,EAAE,CAAC,sBAAsB,CAAC,CAAC,CAAA;IAChF,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kronos-ts/postgres",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "PostgreSQL extension for Kronos — event store and snapshot store adapters.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/adapter.ts CHANGED
@@ -41,6 +41,19 @@ export type QueryRow = Record<string, unknown>
41
41
  */
42
42
  export interface PostgresAdapterTransaction {
43
43
  query<R extends QueryRow = QueryRow>(sql: string, params?: unknown[]): Promise<R[]>
44
+ /**
45
+ * Escape hatch returning the live driver-specific handle backing this
46
+ * transaction — the pg `PoolClient`, or the scoped `sql` for postgres.js /
47
+ * Bun.sql. Lets an external query builder (e.g. Drizzle) issue statements on
48
+ * the SAME connection, and therefore the SAME transaction, as the engine's
49
+ * appends — so an application's CRUD writes commit or roll back atomically
50
+ * with its events.
51
+ *
52
+ * The caller owns the cast (the handle type is driver-specific) and the
53
+ * handle is valid ONLY for the lifetime of this transaction — never retain
54
+ * it past the UoW that opened it.
55
+ */
56
+ unwrap<T = unknown>(): T
44
57
  }
45
58
 
46
59
  /** Handle to a live LISTEN subscription. unlisten() unregisters + releases
@@ -173,6 +173,9 @@ export function bunSqlAdapter(config: BunSqlAdapterConfig): PostgresAdapter {
173
173
  // normalized (errno -> code) via normalizeBunSqlError before propagating.
174
174
  return inst.begin(`ISOLATION LEVEL ${isolationLevel}`, async (txSql) => {
175
175
  const tx: PostgresAdapterTransaction = {
176
+ unwrap<T = unknown>(): T {
177
+ return txSql as unknown as T
178
+ },
176
179
  async query<R extends QueryRow = QueryRow>(
177
180
  text: string,
178
181
  params?: unknown[],
@@ -119,6 +119,9 @@ export function pgAdapter(config: PgAdapterConfig): PostgresAdapter {
119
119
  try {
120
120
  await client.query(`BEGIN ISOLATION LEVEL ${isolationLevel}`)
121
121
  const tx: PostgresAdapterTransaction = {
122
+ unwrap<T = unknown>(): T {
123
+ return client as unknown as T
124
+ },
122
125
  async query<R extends QueryRow = QueryRow>(
123
126
  sql: string,
124
127
  params?: unknown[],
@@ -101,6 +101,9 @@ export function postgresAdapter(config: PostgresAdapterConfig): PostgresAdapter
101
101
  // pins to the underlying connection for the duration.
102
102
  return (await c.begin(`ISOLATION LEVEL ${isolationLevel}`, async (txSql) => {
103
103
  const tx: PostgresAdapterTransaction = {
104
+ unwrap<T = unknown>(): T {
105
+ return txSql as unknown as T
106
+ },
104
107
  async query<R extends QueryRow = QueryRow>(
105
108
  text: string,
106
109
  params?: unknown[],
package/src/index.ts CHANGED
@@ -43,6 +43,22 @@ export {
43
43
  // Extension factory (Plan 05)
44
44
  export { postgres, type PostgresConfig } from "./postgres.js"
45
45
 
46
+ // Transaction manager — bridges the framework's TransactionManager lifecycle
47
+ // to adapter.transaction(). Users typically get this wired automatically via
48
+ // `postgres(config)`; exported for direct use when composing UoW runners by
49
+ // hand.
50
+ export { postgresTransactionManager } from "./postgres-transaction-manager.js"
51
+
52
+ // Postgres event scheduler — durable schedule() + cancel() + polling worker
53
+ // that fires due schedules into the event store. Wired into postgres()
54
+ // automatically when a uowFactory with the lazy postgres tx is in place;
55
+ // exported here so users who compose their own wiring can construct one.
56
+ export {
57
+ createPostgresEventScheduler,
58
+ type PostgresEventScheduler,
59
+ type PostgresEventSchedulerConfig,
60
+ } from "./postgres-event-scheduler.js"
61
+
46
62
  // Schema bootstrap + DDL builders — exposed for users who want to run their
47
63
  // own migrations (set `postgres({ bootstrap: false })`) or drive the store
48
64
  // directly without going through the extension factory.
@@ -51,6 +67,8 @@ export {
51
67
  buildEventsTableDDL,
52
68
  buildEventsIndexesDDL,
53
69
  buildSnapshotsTableDDL,
70
+ buildScheduledEventsTableDDL,
71
+ buildScheduledEventsIndexesDDL,
54
72
  DEFAULT_TABLE_NAMES,
55
73
  type TableNames,
56
74
  } from "./schema.js"
@@ -0,0 +1,314 @@
1
+ /**
2
+ * createPostgresEventScheduler — durable {@link EventScheduler} backed by
3
+ * the kronos_scheduled_events table, plus a polling worker that fires
4
+ * due schedules into the event store.
5
+ *
6
+ * # Schedule path (caller-driven, inside a UoW)
7
+ *
8
+ * schedule(event, at)
9
+ * → requires INVOCATION phase
10
+ * → captures tags via the configured TagResolver at schedule-time
11
+ * → INSERT row (schedule_id = event.identifier, status='pending')
12
+ * → joins the active UoW transaction via getOrBeginActiveTransaction;
13
+ * if the UoW rolls back, the schedule is never persisted
14
+ * → returns { id: schedule_id } as the cancellation token
15
+ *
16
+ * # Cancel path
17
+ *
18
+ * cancel(token)
19
+ * → SELECT … FOR UPDATE to lock the row + read prior status
20
+ * → branch:
21
+ * status='pending' → UPDATE status='cancelled' → { kind: 'cancelled' }
22
+ * status='appended' → no UPDATE → { kind: 'already-appended' }
23
+ * status='cancelled' → no UPDATE → { kind: 'not-found' }
24
+ * no row → no UPDATE → { kind: 'not-found' }
25
+ * → cancel inside a UoW joins the active tx; outside, opens its own
26
+ * adapter.transaction so the SELECT-FOR-UPDATE + UPDATE land atomically
27
+ *
28
+ * # Worker path (background)
29
+ *
30
+ * start() spins a setInterval that, per tick, runs inside a fresh UoW:
31
+ * 1. force the lazy pg tx open via getOrBeginActiveTransaction
32
+ * 2. SELECT … WHERE status='pending' AND fire_at <= now()
33
+ * ORDER BY fire_at LIMIT $batchSize FOR UPDATE SKIP LOCKED
34
+ * — the SKIP LOCKED keeps multiple worker instances safe
35
+ * 3. for each row: reconstruct EventMessage, call eventStore.append
36
+ * (which joins the same UoW tx), UPDATE status='appended'
37
+ * 4. UoW COMMIT → all appends + status flips land atomically
38
+ *
39
+ * If the worker process dies mid-tick before COMMIT, the rows stay
40
+ * 'pending' and get re-picked on the next tick — at-least-once delivery.
41
+ * The schedule_id is reused as event.identifier so the events table's
42
+ * UNIQUE constraint dedupes any spurious double-append (e.g., on the
43
+ * rare race where a previous COMMIT succeeded but the status UPDATE
44
+ * failed afterwards — though here they share a tx, so this is mainly
45
+ * a defensive note).
46
+ *
47
+ * # Multi-node safety
48
+ *
49
+ * FOR UPDATE SKIP LOCKED is the locking primitive. Two worker processes
50
+ * polling the same table will hand non-overlapping batches of rows to
51
+ * their respective ticks; neither blocks the other. No leader election
52
+ * or distributed lock is needed.
53
+ */
54
+
55
+ import { qualifiedNameToString, qualifiedNameFromString } from "@kronos-ts/common"
56
+ import type { EventMessage } from "@kronos-ts/messaging"
57
+ import type {
58
+ EventScheduler,
59
+ ScheduleToken,
60
+ CancelResult,
61
+ UoWRunner,
62
+ } from "@kronos-ts/messaging"
63
+ import { getOrBeginActiveTransaction } from "@kronos-ts/messaging"
64
+ // Deep-path import: requireInvocationPhase is intentionally not in the
65
+ // messaging barrel — it's the framework-internal mutator guard consumed
66
+ // by append/send/emitUpdate, and now schedule(). Same access pattern.
67
+ import { requireInvocationPhase } from "@kronos-ts/messaging/processing-state"
68
+ import type { EventStore } from "@kronos-ts/eventsourcing"
69
+ import { IsolationLevel } from "./adapter.js"
70
+ import type { PostgresAdapter, PostgresAdapterTransaction } from "./adapter.js"
71
+ import { encodeTag } from "./criteria-sql.js"
72
+ import { type TableNames, DEFAULT_TABLE_NAMES } from "./schema.js"
73
+ import type { TagResolver } from "./postgres-event-store.js"
74
+
75
+ export interface PostgresEventSchedulerConfig {
76
+ readonly adapter: PostgresAdapter
77
+ readonly eventStore: EventStore
78
+ /**
79
+ * The composed UoW runner the worker uses per tick — must be wrapped
80
+ * with {@link lazyTransactionalUnitOfWorkFactory} (or equivalent) so
81
+ * the worker's UoW sees the postgres tx. Typically the resolved
82
+ * `unitOfWorkFactory` slot.
83
+ */
84
+ readonly uowFactory: UoWRunner
85
+ readonly tagResolver: TagResolver
86
+ readonly tableNames?: TableNames
87
+ /**
88
+ * Worker poll interval. Defaults to 1000ms — a compromise between
89
+ * fire-latency and DB chatter. Production users wanting tighter
90
+ * latency should lower this, ideally combined with LISTEN/NOTIFY
91
+ * wake-up (not yet wired here).
92
+ */
93
+ readonly pollIntervalMs?: number
94
+ /** Max rows the worker processes per tick. Defaults to 50. */
95
+ readonly batchSize?: number
96
+ }
97
+
98
+ export interface PostgresEventScheduler extends EventScheduler {
99
+ /** Begin background polling. Idempotent. */
100
+ start(): Promise<void>
101
+ /** Stop polling. Resolves once the in-flight tick (if any) has settled. */
102
+ stop(): Promise<void>
103
+ }
104
+
105
+ interface ScheduleRow {
106
+ schedule_id: string
107
+ type: string
108
+ tags: string[]
109
+ payload: unknown
110
+ metadata: unknown
111
+ version: string
112
+ message_timestamp: string | number
113
+ [key: string]: unknown
114
+ }
115
+
116
+ export function createPostgresEventScheduler(
117
+ config: PostgresEventSchedulerConfig,
118
+ ): PostgresEventScheduler {
119
+ const { adapter, eventStore, uowFactory, tagResolver } = config
120
+ const tables = config.tableNames ?? DEFAULT_TABLE_NAMES
121
+ const pollIntervalMs = config.pollIntervalMs ?? 1000
122
+ const batchSize = config.batchSize ?? 50
123
+
124
+ async function insertSchedule(
125
+ tx: PostgresAdapterTransaction,
126
+ event: EventMessage,
127
+ at: Date,
128
+ ): Promise<string> {
129
+ const scheduleId = event.identifier
130
+ const encodedTags = tagResolver
131
+ .resolve(event)
132
+ .map((t) => encodeTag(t.key, t.value))
133
+ await tx.query(
134
+ `INSERT INTO ${tables.scheduled}
135
+ (schedule_id, fire_at, status, type, tags, payload, metadata, version, message_timestamp)
136
+ VALUES ($1, $2, 'pending', $3, $4, $5, $6, $7, $8)`,
137
+ [
138
+ scheduleId,
139
+ at.toISOString(),
140
+ qualifiedNameToString(event.name),
141
+ encodedTags,
142
+ JSON.stringify(event.payload ?? {}),
143
+ JSON.stringify(event.metadata ?? {}),
144
+ event.version,
145
+ event.timestamp,
146
+ ],
147
+ )
148
+ return scheduleId
149
+ }
150
+
151
+ async function cancelOnTx(
152
+ tx: PostgresAdapterTransaction,
153
+ scheduleId: string,
154
+ ): Promise<CancelResult> {
155
+ const rows = await tx.query<{ status: string }>(
156
+ `SELECT status FROM ${tables.scheduled} WHERE schedule_id = $1 FOR UPDATE`,
157
+ [scheduleId],
158
+ )
159
+ const row = rows[0]
160
+ if (!row) return { kind: "not-found" }
161
+ if (row.status === "appended") return { kind: "already-appended" }
162
+ if (row.status === "cancelled") return { kind: "not-found" }
163
+
164
+ await tx.query(
165
+ `UPDATE ${tables.scheduled} SET status = 'cancelled' WHERE schedule_id = $1`,
166
+ [scheduleId],
167
+ )
168
+ return { kind: "cancelled" }
169
+ }
170
+
171
+ function decodeJsonbValue(v: unknown): unknown {
172
+ if (typeof v === "string") {
173
+ try {
174
+ return JSON.parse(v)
175
+ } catch {
176
+ return v
177
+ }
178
+ }
179
+ return v ?? {}
180
+ }
181
+
182
+ function decodeTags(encoded: string[]): EventMessage["tags"] {
183
+ return encoded.map((t) => {
184
+ // U+001F unit separator — matches encodeTag in criteria-sql.ts
185
+ const sep = t.indexOf("")
186
+ return sep >= 0
187
+ ? { key: t.slice(0, sep), value: t.slice(sep + 1) }
188
+ : { key: t, value: "" }
189
+ })
190
+ }
191
+
192
+ function reconstructEvent(row: ScheduleRow): EventMessage {
193
+ return {
194
+ identifier: row.schedule_id,
195
+ name: qualifiedNameFromString(row.type),
196
+ payload: decodeJsonbValue(row.payload),
197
+ metadata: decodeJsonbValue(row.metadata) as EventMessage["metadata"],
198
+ timestamp: Number(row.message_timestamp),
199
+ version: row.version,
200
+ tags: decodeTags(row.tags),
201
+ }
202
+ }
203
+
204
+ // Worker state
205
+ let running = false
206
+ let timer: ReturnType<typeof setTimeout> | undefined
207
+ let activeTick: Promise<void> | undefined
208
+
209
+ async function tick(): Promise<void> {
210
+ try {
211
+ await uowFactory(undefined, async () => {
212
+ const tx = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
213
+ if (!tx) {
214
+ // No lazy tx factory installed on the UoW — the scheduler was
215
+ // configured with a uowFactory that doesn't wrap the postgres tx
216
+ // manager. Without a shared tx the worker can't atomically
217
+ // append-and-mark, so refuse to fire rather than risk
218
+ // partial-state. This is a misconfiguration; surface loudly.
219
+ throw new Error(
220
+ "postgresEventScheduler worker requires a uowFactory wrapped with lazyTransactionalUnitOfWorkFactory + postgresTransactionManager",
221
+ )
222
+ }
223
+
224
+ const rows = await tx.query<ScheduleRow>(
225
+ `SELECT schedule_id, type, tags, payload, metadata, version, message_timestamp
226
+ FROM ${tables.scheduled}
227
+ WHERE status = 'pending' AND fire_at <= now()
228
+ ORDER BY fire_at
229
+ LIMIT $1
230
+ FOR UPDATE SKIP LOCKED`,
231
+ [batchSize],
232
+ )
233
+ if (rows.length === 0) return
234
+
235
+ for (const row of rows) {
236
+ const event = reconstructEvent(row)
237
+ // eventStore.append joins our shared UoW tx via
238
+ // getOrBeginActiveTransaction (postgres-event-store refactor).
239
+ // event_id = schedule_id, so re-fires after a crash dedupe via
240
+ // the events table's UNIQUE(event_id) constraint.
241
+ await eventStore.append([event])
242
+ await tx.query(
243
+ `UPDATE ${tables.scheduled} SET status = 'appended' WHERE schedule_id = $1`,
244
+ [row.schedule_id],
245
+ )
246
+ }
247
+ })
248
+ } catch (err) {
249
+ // A failed tick leaves rows 'pending' (the UoW rolls back the whole
250
+ // batch). The next tick re-tries. Log so operators see persistent
251
+ // failures rather than a silent hang.
252
+ console.warn("postgresEventScheduler: worker tick failed:", err)
253
+ }
254
+ }
255
+
256
+ function scheduleNextTick(): void {
257
+ if (!running) return
258
+ timer = setTimeout(() => {
259
+ activeTick = tick().finally(() => {
260
+ activeTick = undefined
261
+ scheduleNextTick()
262
+ })
263
+ }, pollIntervalMs)
264
+ }
265
+
266
+ return {
267
+ async schedule(event: EventMessage, at: Date): Promise<ScheduleToken> {
268
+ requireInvocationPhase()
269
+ const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
270
+ if (shared === undefined) {
271
+ // schedule() must be transactional with the caller's UoW so a
272
+ // rolled-back command does not leak schedules. Refuse rather
273
+ // than open a side-channel tx the caller can't roll back.
274
+ throw new Error(
275
+ "postgresEventScheduler.schedule requires a UoW with a postgres transaction (configure lazyTransactionalUnitOfWorkFactory + postgresTransactionManager)",
276
+ )
277
+ }
278
+ const id = await insertSchedule(shared, event, at)
279
+ return { id }
280
+ },
281
+
282
+ async cancel(token: ScheduleToken): Promise<CancelResult> {
283
+ const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
284
+ if (shared !== undefined) {
285
+ return cancelOnTx(shared, token.id)
286
+ }
287
+ return adapter.transaction(IsolationLevel.READ_COMMITTED, (tx) =>
288
+ cancelOnTx(tx, token.id),
289
+ )
290
+ },
291
+
292
+ async start(): Promise<void> {
293
+ if (running) return
294
+ running = true
295
+ scheduleNextTick()
296
+ },
297
+
298
+ async stop(): Promise<void> {
299
+ running = false
300
+ if (timer !== undefined) {
301
+ clearTimeout(timer)
302
+ timer = undefined
303
+ }
304
+ if (activeTick !== undefined) {
305
+ try {
306
+ await activeTick
307
+ } catch {
308
+ // The tick logs its own failures; stop() returns successfully
309
+ // either way so callers can shut down deterministically.
310
+ }
311
+ }
312
+ },
313
+ }
314
+ }
@@ -49,7 +49,13 @@ import type {
49
49
  StreamingCondition,
50
50
  TrackingToken,
51
51
  } from "@kronos-ts/messaging"
52
- import { createMessageStream, globalSequenceToken, FIRST_TOKEN } from "@kronos-ts/messaging"
52
+ import {
53
+ createMessageStream,
54
+ globalSequenceToken,
55
+ FIRST_TOKEN,
56
+ getOrBeginActiveTransaction,
57
+ onAfterCommit,
58
+ } from "@kronos-ts/messaging"
53
59
  import { qualifiedNameToString, qualifiedNameFromString } from "@kronos-ts/common"
54
60
  import type { Serializer } from "@kronos-ts/common"
55
61
  export type { Serializer } from "@kronos-ts/common"
@@ -325,31 +331,54 @@ export function createPostgresEventStore(
325
331
  events: ReadonlyArray<EventMessage>,
326
332
  condition?: AppendCondition,
327
333
  ): Promise<ConsistencyMarker> {
328
- // Convenience: appendEvents + commit + afterCommit in one shot.
334
+ // Two paths: join an active UoW tx (writes commit atomically with
335
+ // everything else in the UoW — scheduler inserts, future outbox, etc.),
336
+ // or open our own short-lived tx when no UoW is active. The shared
337
+ // path defers NOTIFY + subscriber dispatch to AFTER_COMMIT because the
338
+ // tx hasn't actually committed yet when checkAndInsert returns.
329
339
  const targets = lockTargetsForCondition(condition)
330
- let marker: ConsistencyMarker
331
- try {
332
- marker = await adapter.transaction(IsolationLevel.READ_COMMITTED, async (tx) => {
333
- await acquireWriteLocks(tx, targets)
334
- const captured = await checkAndInsert(tx, events, condition)
335
- return markerAt(captured.position)
336
- })
337
- } catch (err) {
338
- if (isDcbViolation(err)) {
339
- // Re-throw AppendConditionError directly (already has correct type)
340
- throw err
340
+ const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
341
+
342
+ const notifyAndFanout = async () => {
343
+ await adapter.query(`NOTIFY ${notifyChannel}`)
344
+ for (const sub of eventSubscribers) {
345
+ try { await sub(events) } catch { /* ignore subscriber errors */ }
341
346
  }
347
+ }
348
+
349
+ const runAppend = async (tx: PostgresAdapterTransaction): Promise<ConsistencyMarker> => {
350
+ await acquireWriteLocks(tx, targets)
351
+ const captured = await checkAndInsert(tx, events, condition)
352
+ return markerAt(captured.position)
353
+ }
354
+
355
+ const translateError = (err: unknown): never => {
356
+ if (isDcbViolation(err)) throw err
342
357
  if ((err as { code?: string }).code === "23505") {
343
358
  throw AppendConditionError.fromConflictCount(0, condition?.marker.position ?? -1n)
344
359
  }
345
360
  throw err
346
361
  }
347
- // Wake up tailing streams + notify push-based subscribers
348
- await adapter.query(`NOTIFY ${notifyChannel}`)
349
- for (const sub of eventSubscribers) {
350
- try { await sub(events) } catch { /* ignore subscriber errors */ }
362
+
363
+ if (shared !== undefined) {
364
+ let marker: ConsistencyMarker
365
+ try {
366
+ marker = await runAppend(shared)
367
+ } catch (err) {
368
+ translateError(err)
369
+ }
370
+ onAfterCommit(notifyAndFanout)
371
+ return marker!
372
+ }
373
+
374
+ let marker: ConsistencyMarker
375
+ try {
376
+ marker = await adapter.transaction(IsolationLevel.READ_COMMITTED, runAppend)
377
+ } catch (err) {
378
+ translateError(err)
351
379
  }
352
- return marker
380
+ await notifyAndFanout()
381
+ return marker!
353
382
  },
354
383
 
355
384
  async getHeadPosition(): Promise<bigint> {
@@ -372,26 +401,44 @@ export function createPostgresEventStore(
372
401
  },
373
402
 
374
403
  async publish(events: ReadonlyArray<EventMessage>): Promise<void> {
375
- // publish = append without condition; also notifies subscribers + streams
404
+ // publish = append without condition; same shared/own tx split as append().
376
405
  const targets: LockTarget[] = []
377
- let marker: ConsistencyMarker
406
+ const shared = await getOrBeginActiveTransaction<PostgresAdapterTransaction>()
407
+
408
+ const notifyAndFanout = async () => {
409
+ await adapter.query(`NOTIFY ${notifyChannel}`)
410
+ for (const sub of eventSubscribers) {
411
+ try { await sub(events) } catch { /* ignore subscriber errors */ }
412
+ }
413
+ }
414
+
415
+ const runPublish = async (tx: PostgresAdapterTransaction): Promise<void> => {
416
+ await acquireWriteLocks(tx, targets)
417
+ await checkAndInsert(tx, events, undefined)
418
+ }
419
+
420
+ if (shared !== undefined) {
421
+ try {
422
+ await runPublish(shared)
423
+ } catch (err) {
424
+ if ((err as { code?: string }).code === "23505") {
425
+ throw AppendConditionError.fromConflictCount(0, -1n)
426
+ }
427
+ throw err
428
+ }
429
+ onAfterCommit(notifyAndFanout)
430
+ return
431
+ }
432
+
378
433
  try {
379
- marker = await adapter.transaction(IsolationLevel.READ_COMMITTED, async (tx) => {
380
- await acquireWriteLocks(tx, targets)
381
- const captured = await checkAndInsert(tx, events, undefined)
382
- return markerAt(captured.position)
383
- })
434
+ await adapter.transaction(IsolationLevel.READ_COMMITTED, runPublish)
384
435
  } catch (err) {
385
436
  if ((err as { code?: string }).code === "23505") {
386
437
  throw AppendConditionError.fromConflictCount(0, -1n)
387
438
  }
388
439
  throw err
389
440
  }
390
- void marker
391
- await adapter.query(`NOTIFY ${notifyChannel}`)
392
- for (const sub of eventSubscribers) {
393
- try { await sub(events) } catch { /* ignore subscriber errors */ }
394
- }
441
+ await notifyAndFanout()
395
442
  },
396
443
 
397
444
  subscribe(