@kronos-ts/messaging 0.7.0 → 0.9.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.
@@ -77,6 +77,50 @@ export function globalSequenceToken(sequence: bigint): GlobalSequenceToken {
77
77
  }
78
78
  }
79
79
 
80
+ // ---------------------------------------------------------------------------
81
+ // GapAwareToken
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export interface GapAwareToken extends TrackingToken {
85
+ readonly kind: "gap-aware"
86
+ /** The `sequence_position` of the last consumed event — the `position()`. */
87
+ readonly sequence: bigint
88
+ /**
89
+ * An opaque, store-defined commit-order key that, paired with `sequence`,
90
+ * forms a gap-free tailing cursor. For the Postgres engine this is the
91
+ * event's `transaction_id` (xid8): the durable token MUST carry it because
92
+ * gap-free tailing orders by `(transaction_id, sequence_position)` and only
93
+ * `transaction_id` has a commit watermark (`pg_snapshot_xmin`). A position
94
+ * alone cannot resume the stream without permanently skipping events whose
95
+ * `sequence_position` is lower but whose `transaction_id` is higher (the
96
+ * xid/seq inversion that happens when a transaction writes other rows —
97
+ * stamping its xid — before appending its event).
98
+ */
99
+ readonly gapKey: string
100
+ }
101
+
102
+ /**
103
+ * Creates a token for a gap-free tailing engine: a `sequence` position paired
104
+ * with an opaque `gapKey` (the store's commit-order key, e.g. Postgres xid8).
105
+ * `position()` returns the sequence so replay/`covers` semantics are unchanged;
106
+ * the `gapKey` rides along so the engine can resume the `(gapKey, sequence)`
107
+ * cursor exactly on reopen instead of falling back to a lossy position filter.
108
+ */
109
+ export function gapAwareToken(sequence: bigint, gapKey: string): GapAwareToken {
110
+ return {
111
+ kind: "gap-aware",
112
+ sequence,
113
+ gapKey,
114
+ position: () => sequence,
115
+ covers: (other) => sequence >= other.position(),
116
+ lowerBound: (other) =>
117
+ sequence <= other.position() ? gapAwareToken(sequence, gapKey) : globalSequenceToken(other.position()),
118
+ upperBound: (other) =>
119
+ sequence >= other.position() ? gapAwareToken(sequence, gapKey) : globalSequenceToken(other.position()),
120
+ samePositionAs: (other) => sequence === other.position(),
121
+ }
122
+ }
123
+
80
124
  /**
81
125
  * Sentinel token representing the beginning of the event stream.
82
126
  * A processor starting with FIRST_TOKEN will read from position 0.
@@ -171,28 +215,87 @@ export function isGlobalSequenceToken(token: TrackingToken): token is GlobalSequ
171
215
  return token.kind === "global-sequence"
172
216
  }
173
217
 
218
+ export function isGapAwareToken(token: TrackingToken): token is GapAwareToken {
219
+ return token.kind === "gap-aware"
220
+ }
221
+
174
222
  // ---------------------------------------------------------------------------
175
223
  // Token operations
176
224
  // ---------------------------------------------------------------------------
177
225
 
226
+ /**
227
+ * Advance a token to the position represented by `next`, preserving replay
228
+ * wrapping. Generalises {@link advanceToken} to any TrackingToken — used when
229
+ * the event source supplies its own cursor token (e.g. a {@link GapAwareToken}
230
+ * carrying a commit-order key) that must be persisted verbatim rather than
231
+ * collapsed to a bare position.
232
+ */
233
+ export function advanceTokenTo(token: TrackingToken, next: TrackingToken): TrackingToken {
234
+ if (!isReplayToken(token)) {
235
+ return next
236
+ }
237
+
238
+ // Check if replay is complete
239
+ if (next.covers(token.tokenAtReset)) {
240
+ return next
241
+ }
242
+
243
+ // Still replaying — wrap the advanced token
244
+ return replayToken(token.tokenAtReset, next, token.resetContext)
245
+ }
246
+
178
247
  /**
179
248
  * Advance a token to a new position. If the token is a ReplayToken and
180
249
  * the new position covers the reset point, unwraps to a plain token.
181
250
  */
182
251
  export function advanceToken(token: TrackingToken, newPosition: bigint): TrackingToken {
183
- const advanced = globalSequenceToken(newPosition)
252
+ return advanceTokenTo(token, globalSequenceToken(newPosition))
253
+ }
184
254
 
185
- if (!isReplayToken(token)) {
186
- return advanced
187
- }
255
+ // ---------------------------------------------------------------------------
256
+ // Serialization
257
+ // ---------------------------------------------------------------------------
188
258
 
189
- // Check if replay is complete
190
- if (advanced.covers(token.tokenAtReset)) {
191
- return advanced
259
+ /** Wire shape for a persisted token: a `kind` discriminant + a JSON body. */
260
+ export interface SerializedToken {
261
+ readonly type: string
262
+ readonly data: string
263
+ }
264
+
265
+ /**
266
+ * Serialize a token for durable storage. The JSON body always carries
267
+ * `position`; when the token (or, for a ReplayToken, its innermost token) is a
268
+ * {@link GapAwareToken}, the `gapKey` is preserved so the cursor resumes
269
+ * exactly on reload. ReplayTokens still flatten to their current position on
270
+ * the wire (replay-in-progress state does not survive a restart, as before),
271
+ * but the gapKey survives so live tailing resumes without skipping events.
272
+ */
273
+ export function serializeToken(token: TrackingToken): SerializedToken {
274
+ const inner = unwrapToken(token)
275
+ const payload: { position: string; gapKey?: string } = {
276
+ position: token.position().toString(),
192
277
  }
278
+ if (isGapAwareToken(inner)) {
279
+ payload.gapKey = inner.gapKey
280
+ }
281
+ return { type: token.kind, data: JSON.stringify(payload) }
282
+ }
193
283
 
194
- // Still replaying — wrap the advanced position
195
- return replayToken(token.tokenAtReset, advanced, token.resetContext)
284
+ /**
285
+ * Reconstruct a token from its persisted form. A body carrying a `gapKey`
286
+ * rehydrates as a {@link GapAwareToken}; otherwise a {@link GlobalSequenceToken}.
287
+ * Returns undefined when there is no stored token.
288
+ */
289
+ export function deserializeToken(
290
+ type: string | null | undefined,
291
+ data: string | null | undefined,
292
+ ): TrackingToken | undefined {
293
+ if (!data) return undefined
294
+ const parsed = JSON.parse(data) as { position: string; gapKey?: string }
295
+ if (parsed.gapKey !== undefined) {
296
+ return gapAwareToken(BigInt(parsed.position), parsed.gapKey)
297
+ }
298
+ return globalSequenceToken(BigInt(parsed.position))
196
299
  }
197
300
 
198
301
  /**