@kronos-ts/postgres 0.1.0 → 0.2.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/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