@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.
- package/README.md +81 -0
- package/dist/adapter.d.ts +13 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapters/bun-sql.d.ts.map +1 -1
- package/dist/adapters/bun-sql.js +3 -0
- package/dist/adapters/bun-sql.js.map +1 -1
- package/dist/adapters/pg.d.ts.map +1 -1
- package/dist/adapters/pg.js +3 -0
- package/dist/adapters/pg.js.map +1 -1
- package/dist/adapters/postgres.d.ts.map +1 -1
- package/dist/adapters/postgres.js +3 -0
- package/dist/adapters/postgres.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/postgres-event-scheduler.d.ts +88 -0
- package/dist/postgres-event-scheduler.d.ts.map +1 -0
- package/dist/postgres-event-scheduler.js +226 -0
- package/dist/postgres-event-scheduler.js.map +1 -0
- package/dist/postgres-event-store.d.ts.map +1 -1
- package/dist/postgres-event-store.js +70 -35
- package/dist/postgres-event-store.js.map +1 -1
- package/dist/postgres-transaction-manager.d.ts +33 -0
- package/dist/postgres-transaction-manager.d.ts.map +1 -0
- package/dist/postgres-transaction-manager.js +92 -0
- package/dist/postgres-transaction-manager.js.map +1 -0
- package/dist/postgres.d.ts +22 -5
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +54 -5
- package/dist/postgres.js.map +1 -1
- package/dist/schema.d.ts +47 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +67 -0
- package/dist/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter.ts +13 -0
- package/src/adapters/bun-sql.ts +3 -0
- package/src/adapters/pg.ts +3 -0
- package/src/adapters/postgres.ts +3 -0
- package/src/index.ts +18 -0
- package/src/postgres-event-scheduler.ts +314 -0
- package/src/postgres-event-store.ts +77 -30
- package/src/postgres-transaction-manager.ts +120 -0
- package/src/postgres.ts +68 -5
- 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 {
|
package/dist/schema.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;
|
|
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
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
|
package/src/adapters/bun-sql.ts
CHANGED
|
@@ -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[],
|
package/src/adapters/pg.ts
CHANGED
|
@@ -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[],
|
package/src/adapters/postgres.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
try {
|
|
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
|
-
|
|
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;
|
|
404
|
+
// publish = append without condition; same shared/own tx split as append().
|
|
376
405
|
const targets: LockTarget[] = []
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|