@vlasky/zongji 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to @vlasky/zongji since forking from nevill/zongji.
4
4
 
5
+ ## [0.9.0] - 2026-07-06
6
+
7
+ - Export the `MariadbGtidPosition` class (`import { MariadbGtidPosition } from '@vlasky/zongji'`), the MariaDB counterpart of `GtidSet` and the model behind `zongji.gtidSet` on MariaDB servers: parse a persisted position (a `zongji.gtidSet` checkpoint or `@@gtid_binlog_pos`), build one from a `mariadbgtidlist` event with `fromGtidList()`, extend it with `add()`, and test transaction coverage with `covers(event.gtid, { inclusive })` instead of hand-rolling sequence-number comparators (which silently round above `Number.MAX_SAFE_INTEGER` when done with Number). The inclusive form (default) answers "was this transaction's commit at or before the position?" for snapshot barriers; redelivery watermarks must pass `{ inclusive: false }`, because all events of a transaction share one GTID and an inclusive check would drop every replayed event of the watermark transaction after the first. A server id differing from the position's entry for that domain reports not covered even for a lower sequence number (post-failover sequence numbers can regress under the new server id; for at-least-once consumers an idempotent redelivery is recoverable where a skip is not), as does a domain the position does not list.
8
+ - `GtidSet` and `MariadbGtidPosition` now use BigInt internally, making them exact over MySQL's full GNO range (1 to 2^63-1) and MariaDB's full u64 sequence range. Previously `GtidSet.parse`/`add` threw on GNOs above 2^53, so a snapshot barrier or checkpoint parse failed outright against a server carrying such GTIDs. Public APIs are unchanged: strings in, strings and booleans out.
9
+ - Fix Previous_gtids decoding silently rounding interval bounds above 2^53: the u64 bounds were combined with Number arithmetic, corrupting `event.gtidSet` - the exact text that seeds `zongji.gtidSet` when a dump starts at a binlog file boundary. Bounds are now decoded as BigInt, and the `event.sids` intervals follow the Number-or-exact-string convention used for other 64-bit values (typed `number | string`; previously typed `number` while silently wrong beyond 2^53).
10
+ - Document on the `seqNo` fields of `mariadbgtid` and `mariadbgtidlist` events that values above `Number.MAX_SAFE_INTEGER` arrive as exact strings and must never be compared numerically - use `event.gtid` with `MariadbGtidPosition`/`GtidSet` instead.
11
+
5
12
  ## [0.8.0] - 2026-07-06
6
13
 
7
14
  - Export the `GtidSet` class (`import ZongJi, { GtidSet } from '@vlasky/zongji'`): parse a persisted set (e.g. a `zongji.gtidSet` checkpoint or `@@gtid_executed`), `add()` transactions, and test single-transaction membership with `contains(event.gtid)` - handy for snapshot drain barriers ("was this event already covered when I took my snapshot?") without maintaining a separate GTID parser. Fully tagged-GTID aware.
package/README.md CHANGED
@@ -224,6 +224,18 @@ if (!snapshot.contains(event.gtid)) {
224
224
  }
225
225
  ```
226
226
 
227
+ Its MariaDB counterpart `MariadbGtidPosition` is exported too, with a `covers()` method so consumers do not have to compare sequence numbers themselves (`seqNo` values above `Number.MAX_SAFE_INTEGER` arrive as exact strings, and comparing them as Numbers silently rounds; internally `GtidSet` is exact over MySQL's full GNO range of 1 to 2^63-1 and `MariadbGtidPosition` over MariaDB's full u64 sequence range):
228
+
229
+ ```javascript
230
+ import { MariadbGtidPosition } from '@vlasky/zongji';
231
+
232
+ const snapshot = MariadbGtidPosition.parse(savedGtidPos); // or @@gtid_binlog_pos
233
+ snapshot.covers(event.gtid); // at or before the position?
234
+ snapshot.covers(event.gtid, { inclusive: false }); // strictly before it?
235
+ ```
236
+
237
+ The default (inclusive) form answers "was this transaction's commit at or before the position?", which is what snapshot-barrier checks want: a recorded position covers the whole of its final transaction. Redelivery watermarks must pass `{ inclusive: false }`: all events of a transaction share one GTID, so an inclusive check would treat the watermark transaction's own replayed events as already seen and drop every one after the first. A server id different from the position's entry for that domain reports **not covered**, even for a lower sequence number: after a failover, sequence numbers can regress under the new server id, so ordering against another server's watermark is not meaningful, and for at-least-once consumers an idempotent redelivery is recoverable where a skip is not. A domain the position does not list is likewise not covered.
238
+
227
239
  `zongji.gtidSet` is available when `start()` was given a `gtidSet`, with `startAtEnd` (seeded from the server's `gtid_executed` on MySQL, `gtid_current_pos` on MariaDB), or when reading from the start of a binlog file (seeded from its Previous_gtids or MariaDB GTID list event). It is `undefined` when resuming from an arbitrary mid-file file+position checkpoint, where no exact value can be known. Tagged GTIDs (MySQL 8.3+) are fully supported: tagged transactions appear in the set as `uuid:…:tag:intervals` sections, and a checkpoint containing tags resumes correctly (its wire encoding is only understood by MySQL 8.3+ servers).
228
240
 
229
241
  On MySQL, GTID resume requires `gtid_mode=ON` and a set is a per-source-UUID collection of intervals. On MariaDB, GTIDs are always on and a position is a single watermark per replication domain (`domain-server-sequence`): resuming replays each listed domain after its watermark, and domains not listed replay from the beginning. If the server has purged binlogs your checkpoint still needs, `start()` emits an error (code 1236) naming the problem; MariaDB additionally reports a checkpoint ahead of its binlog as error 1945/1955. A fresh snapshot is then required.
package/index.d.ts CHANGED
@@ -151,8 +151,16 @@ export interface AnonymousGtidEvent extends BinlogEvent {
151
151
 
152
152
  // Previous GTIDs event
153
153
  export interface GtidInterval {
154
- start: number;
155
- end: number;
154
+ /**
155
+ * Interval start GNO, inclusive (exact string above
156
+ * Number.MAX_SAFE_INTEGER)
157
+ */
158
+ start: number | string;
159
+ /**
160
+ * Interval end GNO, exclusive as on the wire (exact string above
161
+ * Number.MAX_SAFE_INTEGER)
162
+ */
163
+ end: number | string;
156
164
  }
157
165
 
158
166
  export interface GtidSidEntry {
@@ -360,7 +368,12 @@ export interface HeartbeatEvent extends BinlogEvent {
360
368
  export interface MariadbGtidEvent extends BinlogEvent {
361
369
  getTypeName(): 'MariadbGtid';
362
370
  getEventName(): 'mariadbgtid';
363
- /** Sequence number within the domain (exact string beyond 2^53) */
371
+ /**
372
+ * Sequence number within the domain. Arrives as an exact string above
373
+ * Number.MAX_SAFE_INTEGER, so never compare it numerically: use
374
+ * event.gtid with MariadbGtidPosition.covers() (or GtidSet on MySQL)
375
+ * instead.
376
+ */
364
377
  seqNo: number | string;
365
378
  /** Replication domain id */
366
379
  domainId: number;
@@ -401,6 +414,11 @@ export interface MariadbGtidEvent extends BinlogEvent {
401
414
  export interface MariadbGtidListEntry {
402
415
  domainId: number;
403
416
  serverId: number;
417
+ /**
418
+ * Sequence number within the domain. Arrives as an exact string above
419
+ * Number.MAX_SAFE_INTEGER, so never compare it numerically: use the
420
+ * gtid string with MariadbGtidPosition.covers() instead.
421
+ */
404
422
  seqNo: number | string;
405
423
  /** Full GTID string (domain-server-sequence) */
406
424
  gtid: string;
@@ -671,3 +689,58 @@ export class GtidSet {
671
689
  /** Canonical text form (sorted; tagged intervals after untagged) */
672
690
  toString(): string;
673
691
  }
692
+
693
+ /**
694
+ * A MariaDB GTID position (the model behind zongji.gtidSet on MariaDB):
695
+ * the last transaction processed per replication domain. String form
696
+ * matches @@gtid_binlog_pos / @@gtid_current_pos, e.g. '0-1-1234,1-3-45'.
697
+ * Exported so consumers can parse persisted positions and test coverage
698
+ * via covers() instead of hand-rolling seqNo comparators (which silently
699
+ * round above Number.MAX_SAFE_INTEGER when done with Number; internally
700
+ * this class is exact over the full u64 sequence range).
701
+ */
702
+ export class MariadbGtidPosition {
703
+ /**
704
+ * Parses a GTID position string ('' gives an empty position). Rejects
705
+ * two entries for one domain, as the server would. Throws on malformed
706
+ * input.
707
+ */
708
+ static parse(text: string): MariadbGtidPosition;
709
+ /**
710
+ * Builds the position implied by a MariadbGtidList event's gtids
711
+ * array: the last entry per domain wins
712
+ */
713
+ static fromGtidList(entries: Array<{
714
+ domainId: number;
715
+ serverId: number;
716
+ seqNo: number | string;
717
+ }>): MariadbGtidPosition;
718
+ /**
719
+ * Records a transaction as the last processed in its domain
720
+ * (an overwrite, not a max: "last processed" survives failovers where
721
+ * sequence numbers regress under a new server id)
722
+ */
723
+ add(gtid: {
724
+ domainId: number;
725
+ serverId: number;
726
+ seqNo: number | string;
727
+ }): void;
728
+ /**
729
+ * Whether a transaction ('domain-server-sequence', e.g. an event.gtid)
730
+ * is covered by this position. inclusive (default true) counts the
731
+ * position's own last transaction as covered (snapshot-barrier
732
+ * semantics); redelivery watermarks must pass { inclusive: false },
733
+ * because all events of a transaction share one GTID and an inclusive
734
+ * check would drop every replayed event of the watermark transaction
735
+ * after the first. A server-id mismatch within a domain and an unknown
736
+ * domain both report not covered; null/undefined is never covered; a
737
+ * malformed string throws.
738
+ */
739
+ covers(
740
+ gtid: string | null | undefined,
741
+ options?: { inclusive?: boolean },
742
+ ): boolean;
743
+ isEmpty(): boolean;
744
+ /** Text form, sorted by domain ('D-S-N[,D-S-N...]') */
745
+ toString(): string;
746
+ }
package/index.js CHANGED
@@ -989,3 +989,8 @@ export default ZongJi;
989
989
  // already covered by my snapshot?") without their own parser. Handles
990
990
  // tagged GTIDs (MySQL 8.3+).
991
991
  export { GtidSet };
992
+ // Its MariaDB counterpart: the position model behind zongji.gtidSet on
993
+ // MariaDB servers, exported so consumers can parse persisted positions
994
+ // and test coverage via covers() instead of hand-rolling seqNo
995
+ // comparators (which silently round beyond 2^53 when done with Number).
996
+ export { MariadbGtidPosition };
@@ -230,8 +230,7 @@ class Gtid extends BinlogEvent {
230
230
  parser.parseUnsignedNumber(1);
231
231
  const gno =
232
232
  Common.parseSerializationVarintSigned(parser, messageEnd, what);
233
- this.gno = gno >= BigInt(Number.MIN_SAFE_INTEGER) &&
234
- gno <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(gno) : String(gno);
233
+ this.gno = Common.bigintToNumberOrString(gno);
235
234
  this.tag = '';
236
235
  if (nextFieldIs(3)) {
237
236
  parser.parseUnsignedNumber(1);
@@ -307,13 +306,24 @@ class PreviousGtids extends BinlogEvent {
307
306
  'PreviousGtids');
308
307
  const intervals = [];
309
308
  for (let j = 0; j < intervalCount; j++) {
310
- const start = parser.parseUnsignedNumber(8);
311
- const end = parser.parseUnsignedNumber(8);
309
+ // u64 bounds as BigInt: parseUnsignedNumber(8) would silently
310
+ // round beyond 2^53, corrupting the gtidSet text this event seeds
311
+ // the executed-set tracker with
312
+ const start = parser.parseBuffer(8).readBigUInt64LE(0);
313
+ const end = parser.parseBuffer(8).readBigUInt64LE(0);
312
314
  intervals.push({ start, end });
313
315
  }
314
316
  sids.push(tagged ? { sid, tag, intervals } : { sid, intervals });
315
317
  }
316
- this.sids = sids;
318
+ // The public sids follow the Number-or-exact-string convention for
319
+ // 64-bit values (events must stay JSON-serialisable, so no BigInt)
320
+ this.sids = sids.map(entry => ({
321
+ ...entry,
322
+ intervals: entry.intervals.map(({ start, end }) => ({
323
+ start: Common.bigintToNumberOrString(start),
324
+ end: Common.bigintToNumberOrString(end),
325
+ })),
326
+ }));
317
327
  // Entries sharing a uuid print as one block with the untagged
318
328
  // intervals first: 'uuid:1-5:tag_a:1:tag_b:1', matching
319
329
  // @@gtid_executed. The server writes entries in that order already;
@@ -329,9 +339,9 @@ class PreviousGtids extends BinlogEvent {
329
339
  group.sort((a, b) => (a.tag || '') < (b.tag || '') ? -1 : 1);
330
340
  return sid + group.map(entry => {
331
341
  const ranges = entry.intervals.map(interval =>
332
- interval.start === interval.end - 1 ?
342
+ interval.start === interval.end - 1n ?
333
343
  `${interval.start}` :
334
- `${interval.start}-${interval.end - 1}`);
344
+ `${interval.start}-${interval.end - 1n}`);
335
345
  return (entry.tag ? `:${entry.tag}` : '') + `:${ranges.join(':')}`;
336
346
  }).join('');
337
347
  }).join(',');
package/lib/common.js CHANGED
@@ -58,6 +58,14 @@ export function unsignedInt64ToNumberOrString(high, low) {
58
58
  return value <= MAX_SAFE_BIGINT ? Number(value) : value.toString();
59
59
  }
60
60
 
61
+ // An exact BigInt to the Number-or-exact-string convention used for
62
+ // 64-bit values: a Number within the safe integer range, otherwise an
63
+ // exact decimal string.
64
+ export function bigintToNumberOrString(value) {
65
+ return (value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT) ?
66
+ Number(value) : value.toString();
67
+ }
68
+
61
69
  // Combine two unsigned 32-bit halves into an exact signed (two's
62
70
  // complement) 64-bit value. Returns a Number when within the safe integer
63
71
  // range, otherwise an exact string.
package/lib/gtid_set.js CHANGED
@@ -5,9 +5,10 @@
5
5
  // MySQL 8.3+ GTIDs may carry a tag; a (uuid, tag) pair is its own
6
6
  // source, printed merged into the uuid's block after the untagged
7
7
  // intervals: 'uuid:1-5:tag_a:1:tag_b:1' (tags lowercase, sorted).
8
- // Intervals are stored internally as [start, end] inclusive, kept sorted
9
- // and coalesced. GNOs are JavaScript Numbers: MySQL allows up to 2^63-1,
10
- // but real-world transaction counts sit comfortably inside 2^53.
8
+ // Intervals are stored internally as [start, end] inclusive BigInts, kept
9
+ // sorted and coalesced, so the full GNO range MySQL allows (1 to 2^63-1)
10
+ // is handled exactly; the public API stays strings in, strings and
11
+ // booleans out.
11
12
  class GtidSet {
12
13
  constructor() {
13
14
  // uuid (lowercase, dashed) -> Map of tag ('' = untagged) ->
@@ -97,13 +98,17 @@ class GtidSet {
97
98
  }
98
99
  const existing = tags.get(tag) || [];
99
100
  existing.push([start, end]);
100
- existing.sort((a, b) => a[0] - b[0]);
101
+ // BigInt bounds: a subtraction comparator would hand sort() a BigInt,
102
+ // which ToNumber rejects
103
+ existing.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
101
104
  const merged = [existing[0]];
102
105
  for (let i = 1; i < existing.length; i++) {
103
106
  const last = merged[merged.length - 1];
104
107
  const next = existing[i];
105
- if (next[0] <= last[1] + 1) {
106
- last[1] = Math.max(last[1], next[1]);
108
+ if (next[0] <= last[1] + 1n) {
109
+ if (next[1] > last[1]) {
110
+ last[1] = next[1];
111
+ }
107
112
  } else {
108
113
  merged.push(next);
109
114
  }
@@ -178,8 +183,8 @@ class GtidSet {
178
183
  }
179
184
  offset = buffer.writeBigUInt64LE(BigInt(intervals.length), offset);
180
185
  for (const [start, end] of intervals) {
181
- offset = buffer.writeBigUInt64LE(BigInt(start), offset);
182
- offset = buffer.writeBigUInt64LE(BigInt(end) + 1n, offset);
186
+ offset = buffer.writeBigUInt64LE(start, offset);
187
+ offset = buffer.writeBigUInt64LE(end + 1n, offset);
183
188
  }
184
189
  }
185
190
  return buffer;
@@ -222,13 +227,15 @@ function normaliseUuid(text) {
222
227
  }
223
228
 
224
229
  function parsePositiveInt(text, context) {
225
- // Decimal digits only, per the MySQL GTID grammar: Number() alone would
226
- // also accept '1e3', '0x10' or '1.0' and silently reinterpret them
230
+ // Decimal digits only, per the MySQL GTID grammar: BigInt() alone would
231
+ // also accept '0x10' or '0b1' and silently reinterpret them
227
232
  if (!/^\d+$/.test(text)) {
228
233
  throw new Error(`Invalid GTID number in '${context}': '${text}'`);
229
234
  }
230
- const value = Number(text);
231
- if (!Number.isSafeInteger(value) || value < 1) {
235
+ const value = BigInt(text);
236
+ // GNOs are signed 64-bit on the server and start at 1
237
+ // (GNO_END = 2^63 - 1)
238
+ if (value < 1n || value > 0x7fffffffffffffffn) {
232
239
  throw new Error(`Invalid GTID number in '${context}': '${text}'`);
233
240
  }
234
241
  return value;
@@ -9,7 +9,8 @@
9
9
  // are streamed from the beginning.
10
10
  class MariadbGtidPosition {
11
11
  constructor() {
12
- // domainId (number) -> { domainId, serverId, seqNo }
12
+ // domainId (number) -> { domainId, serverId, seqNo }; seqNo is a
13
+ // BigInt so the full u64 sequence range stays exact
13
14
  this._domains = new Map();
14
15
  }
15
16
 
@@ -21,13 +22,7 @@ class MariadbGtidPosition {
21
22
  return position;
22
23
  }
23
24
  for (const entry of String(text).split(',')) {
24
- const parts = entry.trim().split('-');
25
- if (parts.length !== 3) {
26
- throw new Error(`Invalid MariaDB GTID: '${entry.trim()}'`);
27
- }
28
- const domainId = parseUInt32(parts[0], entry);
29
- const serverId = parseUInt32(parts[1], entry);
30
- const seqNo = parseSeqNo(parts[2], entry);
25
+ const { domainId, serverId, seqNo } = parseSingleGtid(entry);
31
26
  if (position._domains.has(domainId)) {
32
27
  throw new Error(
33
28
  `Duplicate replication domain ${domainId} in GTID position ` +
@@ -59,10 +54,46 @@ class MariadbGtidPosition {
59
54
  this._domains.set(gtid.domainId, {
60
55
  domainId: gtid.domainId,
61
56
  serverId: gtid.serverId,
62
- seqNo: gtid.seqNo,
57
+ // event.seqNo follows the Number-or-exact-string convention for
58
+ // 64-bit values; BigInt accepts both and stays exact beyond 2^53
59
+ seqNo: BigInt(gtid.seqNo),
63
60
  });
64
61
  }
65
62
 
63
+ // Whether a transaction ('domain-server-sequence', e.g. an event.gtid)
64
+ // is covered by this position. A position names the last transaction
65
+ // processed per domain, and within a domain the binlog order is total,
66
+ // so any earlier sequence number from the same server is covered.
67
+ //
68
+ // inclusive (default true) decides whether the position's own last
69
+ // transaction counts as covered. A recorded position means that
70
+ // transaction completed, so snapshot-barrier checks ("did my snapshot
71
+ // already include this commit?") want the default. Redelivery
72
+ // watermarks must pass { inclusive: false }: all events of a
73
+ // transaction share one GTID, so an inclusive check would treat the
74
+ // watermark transaction's own replayed events as already seen and drop
75
+ // every one after the first.
76
+ //
77
+ // A server-id mismatch within a domain reports NOT covered even for a
78
+ // lower sequence number: after a failover, sequence numbers can regress
79
+ // under the new server id, so ordering against another server's
80
+ // watermark is not meaningful; for at-least-once consumers an
81
+ // idempotent redelivery is recoverable where a skip is not. An unknown
82
+ // domain is likewise not covered. null/undefined (an event.gtid seen
83
+ // before the stream's first GTID event) is never covered; a malformed
84
+ // string throws, as it would corrupt rather than answer.
85
+ covers(gtid, { inclusive = true } = {}) {
86
+ if (gtid === undefined || gtid === null) {
87
+ return false;
88
+ }
89
+ const { domainId, serverId, seqNo } = parseSingleGtid(gtid);
90
+ const last = this._domains.get(domainId);
91
+ if (!last || last.serverId !== serverId) {
92
+ return false;
93
+ }
94
+ return inclusive ? seqNo <= last.seqNo : seqNo < last.seqNo;
95
+ }
96
+
66
97
  isEmpty() {
67
98
  return this._domains.size === 0;
68
99
  }
@@ -76,6 +107,19 @@ class MariadbGtidPosition {
76
107
  }
77
108
  }
78
109
 
110
+ function parseSingleGtid(gtid) {
111
+ const entry = String(gtid).trim();
112
+ const parts = entry.split('-');
113
+ if (parts.length !== 3) {
114
+ throw new Error(`Invalid MariaDB GTID: '${entry}'`);
115
+ }
116
+ return {
117
+ domainId: parseUInt32(parts[0], entry),
118
+ serverId: parseUInt32(parts[1], entry),
119
+ seqNo: parseSeqNo(parts[2], entry),
120
+ };
121
+ }
122
+
79
123
  function parseUInt32(text, context) {
80
124
  // Decimal digits only: Number() alone would also accept '1e3' or '0x10'
81
125
  if (!/^\d+$/.test(text)) {
@@ -89,19 +133,16 @@ function parseUInt32(text, context) {
89
133
  }
90
134
 
91
135
  function parseSeqNo(text, context) {
136
+ // Decimal digits only: BigInt() alone would also accept '0x10' or '0b1'
92
137
  if (!/^\d+$/.test(text)) {
93
138
  throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
94
139
  }
95
- // seq_no is u64; keep the Number-or-exact-string convention used for
96
- // other 64-bit values
97
- const value = Number(text);
98
- if (Number.isSafeInteger(value)) {
99
- return value;
100
- }
101
- if (BigInt(text) > 0xffffffffffffffffn) {
140
+ // seq_no is u64; BigInt keeps it exact beyond 2^53
141
+ const value = BigInt(text);
142
+ if (value > 0xffffffffffffffffn) {
102
143
  throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
103
144
  }
104
- return text;
145
+ return value;
105
146
  }
106
147
 
107
148
  export { MariadbGtidPosition };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vlasky/zongji",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "MySQL and MariaDB binlog-based change data capture (CDC) for Node.js",
5
5
  "type": "module",
6
6
  "main": "index.js",