@vlasky/zongji 0.7.1 → 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.
@@ -0,0 +1,244 @@
1
+ // A MySQL GTID set: which transactions (per source) are already
2
+ // executed. String form matches @@gtid_executed / Previous_gtids, e.g.
3
+ // 3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5:11-18,
4
+ // 2C256447-3F0D-431B-9A25-BDDF1F1F6EF6:1-27
5
+ // MySQL 8.3+ GTIDs may carry a tag; a (uuid, tag) pair is its own
6
+ // source, printed merged into the uuid's block after the untagged
7
+ // intervals: 'uuid:1-5:tag_a:1:tag_b:1' (tags lowercase, sorted).
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.
12
+ class GtidSet {
13
+ constructor() {
14
+ // uuid (lowercase, dashed) -> Map of tag ('' = untagged) ->
15
+ // array of [start, end] inclusive
16
+ this._sids = new Map();
17
+ }
18
+
19
+ // Accepts '', 'uuid:1-5', 'uuid:1-5:8,uuid2:3',
20
+ // 'uuid:1-5:tag_a:1:tag_b:2-3' (whitespace tolerated). Within a uuid
21
+ // block a non-numeric item is a tag naming the source of the interval
22
+ // items that follow it, per the Gtid_set::add_gtid_text grammar.
23
+ static parse(text) {
24
+ const set = new GtidSet();
25
+ if (!text) {
26
+ return set;
27
+ }
28
+ for (const entry of String(text).split(',')) {
29
+ const parts = entry.trim().split(':');
30
+ if (parts.length < 2) {
31
+ throw new Error(`Invalid GTID set entry: '${entry.trim()}'`);
32
+ }
33
+ const uuid = normaliseUuid(parts[0]);
34
+ let tag = '';
35
+ let intervals = 0;
36
+ // A tag names the interval items that follow it, so a tag section
37
+ // with none is malformed (the leading untagged section may be
38
+ // empty: 'uuid:tag_a:1' has no untagged intervals)
39
+ let sectionEmpty = false;
40
+ for (const item of parts.slice(1)) {
41
+ const trimmed = item.trim();
42
+ if (TAG_REGEX.test(trimmed)) {
43
+ if (sectionEmpty) {
44
+ throw new Error(`GTID tag without intervals: '${entry.trim()}'`);
45
+ }
46
+ tag = trimmed.toLowerCase();
47
+ sectionEmpty = true;
48
+ continue;
49
+ }
50
+ const bounds = trimmed.split('-');
51
+ const start = parsePositiveInt(bounds[0], entry);
52
+ const end = bounds.length > 1 ?
53
+ parsePositiveInt(bounds[1], entry) : start;
54
+ if (bounds.length > 2 || end < start) {
55
+ throw new Error(`Invalid GTID interval: '${entry.trim()}'`);
56
+ }
57
+ set._addInterval(uuid, tag, start, end);
58
+ sectionEmpty = false;
59
+ intervals++;
60
+ }
61
+ if (intervals === 0 || sectionEmpty) {
62
+ throw new Error(`Invalid GTID set entry: '${entry.trim()}'`);
63
+ }
64
+ }
65
+ return set;
66
+ }
67
+
68
+ // Adds a single transaction from a 'uuid:gno' or 'uuid:tag:gno' string
69
+ add(gtid) {
70
+ const { uuid, tag, gno } = parseSingleGtid(gtid);
71
+ this._addInterval(uuid, tag, gno, gno);
72
+ }
73
+
74
+ // Whether a single transaction ('uuid:gno' or 'uuid:tag:gno', e.g. an
75
+ // event.gtid) is already contained in the set. null/undefined (an
76
+ // anonymous transaction's event.gtid) is never contained; a malformed
77
+ // string throws, as it would corrupt rather than answer.
78
+ contains(gtid) {
79
+ if (gtid === undefined || gtid === null) {
80
+ return false;
81
+ }
82
+ const { uuid, tag, gno } = parseSingleGtid(gtid);
83
+ const tags = this._sids.get(uuid);
84
+ const intervals = tags && tags.get(tag);
85
+ if (!intervals) {
86
+ return false;
87
+ }
88
+ return intervals.some(([start, end]) => gno >= start && gno <= end);
89
+ }
90
+
91
+ // Adds [start, end] inclusive for a (uuid, tag) source, keeping
92
+ // intervals sorted and coalesced
93
+ _addInterval(uuid, tag, start, end) {
94
+ let tags = this._sids.get(uuid);
95
+ if (!tags) {
96
+ tags = new Map();
97
+ this._sids.set(uuid, tags);
98
+ }
99
+ const existing = tags.get(tag) || [];
100
+ existing.push([start, end]);
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);
104
+ const merged = [existing[0]];
105
+ for (let i = 1; i < existing.length; i++) {
106
+ const last = merged[merged.length - 1];
107
+ const next = existing[i];
108
+ if (next[0] <= last[1] + 1n) {
109
+ if (next[1] > last[1]) {
110
+ last[1] = next[1];
111
+ }
112
+ } else {
113
+ merged.push(next);
114
+ }
115
+ }
116
+ tags.set(tag, merged);
117
+ }
118
+
119
+ isEmpty() {
120
+ return this._sids.size === 0;
121
+ }
122
+
123
+ // Sorted (uuid, then tag, untagged first) flat list of
124
+ // [uuid, tag, intervals] entries: the order both the text form and
125
+ // the wire encoding require
126
+ _sortedEntries() {
127
+ return [...this._sids.entries()]
128
+ .sort((a, b) => a[0] < b[0] ? -1 : 1)
129
+ .flatMap(([uuid, tags]) => [...tags.entries()]
130
+ .sort((a, b) => a[0] < b[0] ? -1 : 1)
131
+ .map(([tag, intervals]) => [uuid, tag, intervals]));
132
+ }
133
+
134
+ toString() {
135
+ let text = '';
136
+ let lastUuid = null;
137
+ for (const [uuid, tag, intervals] of this._sortedEntries()) {
138
+ const ranges = intervals
139
+ .map(([start, end]) => start === end ? `${start}` : `${start}-${end}`)
140
+ .join(':');
141
+ if (uuid === lastUuid) {
142
+ text += `:${tag}:${ranges}`;
143
+ } else {
144
+ text += (text ? ',' : '') + uuid +
145
+ (tag ? `:${tag}` : '') + `:${ranges}`;
146
+ lastUuid = uuid;
147
+ }
148
+ }
149
+ return text;
150
+ }
151
+
152
+ // Wire encoding used by COM_BINLOG_DUMP_GTID (and Previous_gtids).
153
+ // Untagged sets use the classic layout accepted by every server
154
+ // version: n_sids u64le, then per sid: 16-byte binary UUID,
155
+ // n_intervals u64le, then per interval: start u64le, end-exclusive
156
+ // u64le. A set containing any tagged source switches the whole
157
+ // payload to the tagged format (MySQL 8.3+, sql/rpl_gtid_set.cc
158
+ // encode_nsids_format): the first u64 becomes
159
+ // (1 << 56) | (n_entries << 8) | 1, and every entry carries a tag
160
+ // field (one length byte = length << 1, then the lowercase tag
161
+ // bytes; 0x00 for untagged) between the UUID and the interval count.
162
+ // Servers before 8.3 reject the tagged format as malformed.
163
+ encode() {
164
+ const entries = this._sortedEntries();
165
+ const tagged = entries.some(([, tag]) => tag !== '');
166
+ let length = 8;
167
+ for (const [, tag, intervals] of entries) {
168
+ length += 16 + (tagged ? 1 + tag.length : 0) + 8 +
169
+ intervals.length * 16;
170
+ }
171
+ const buffer = Buffer.alloc(length);
172
+ let offset = 0;
173
+ const nSidsField = tagged ?
174
+ (1n << 56n) | (BigInt(entries.length) << 8n) | 1n :
175
+ BigInt(entries.length);
176
+ offset = buffer.writeBigUInt64LE(nSidsField, offset);
177
+ for (const [uuid, tag, intervals] of entries) {
178
+ offset += Buffer.from(uuid.replace(/-/g, ''), 'hex')
179
+ .copy(buffer, offset);
180
+ if (tagged) {
181
+ offset = buffer.writeUInt8(tag.length << 1, offset);
182
+ offset += buffer.write(tag, offset, 'ascii');
183
+ }
184
+ offset = buffer.writeBigUInt64LE(BigInt(intervals.length), offset);
185
+ for (const [start, end] of intervals) {
186
+ offset = buffer.writeBigUInt64LE(start, offset);
187
+ offset = buffer.writeBigUInt64LE(end + 1n, offset);
188
+ }
189
+ }
190
+ return buffer;
191
+ }
192
+ }
193
+
194
+ const UUID_REGEX =
195
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
196
+
197
+ // Tag grammar per libs/mysql/gtid/tag.cpp: a letter or underscore, then
198
+ // letters, digits or underscores, at most 32 characters, case-folded to
199
+ // lowercase. Cannot be confused with an interval (those start with a
200
+ // digit).
201
+ const TAG_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]{0,31}$/;
202
+
203
+ function parseSingleGtid(gtid) {
204
+ const parts = String(gtid).split(':');
205
+ if (parts.length !== 2 && parts.length !== 3) {
206
+ throw new Error(`Invalid GTID: '${gtid}'`);
207
+ }
208
+ const uuid = normaliseUuid(parts[0]);
209
+ let tag = '';
210
+ if (parts.length === 3) {
211
+ const candidate = parts[1].trim();
212
+ if (!TAG_REGEX.test(candidate)) {
213
+ throw new Error(`Invalid GTID tag in '${gtid}'`);
214
+ }
215
+ tag = candidate.toLowerCase();
216
+ }
217
+ const gno = parsePositiveInt(parts[parts.length - 1].trim(), gtid);
218
+ return { uuid, tag, gno };
219
+ }
220
+
221
+ function normaliseUuid(text) {
222
+ const uuid = text.trim().toLowerCase();
223
+ if (!UUID_REGEX.test(uuid)) {
224
+ throw new Error(`Invalid GTID source UUID: '${text.trim()}'`);
225
+ }
226
+ return uuid;
227
+ }
228
+
229
+ function parsePositiveInt(text, context) {
230
+ // Decimal digits only, per the MySQL GTID grammar: BigInt() alone would
231
+ // also accept '0x10' or '0b1' and silently reinterpret them
232
+ if (!/^\d+$/.test(text)) {
233
+ throw new Error(`Invalid GTID number in '${context}': '${text}'`);
234
+ }
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) {
239
+ throw new Error(`Invalid GTID number in '${context}': '${text}'`);
240
+ }
241
+ return value;
242
+ }
243
+
244
+ export { GtidSet };
@@ -0,0 +1,148 @@
1
+ // A MariaDB GTID position: the last transaction processed per replication
2
+ // domain. String form matches @@gtid_binlog_pos / @@gtid_current_pos /
3
+ // @slave_connect_state, e.g. '0-1-1234,1-3-45'. Unlike MySQL GTID sets
4
+ // there are no intervals: within a domain the binlog order is total, so a
5
+ // single domain-server-sequence triple identifies everything before it,
6
+ // and a position holds at most one entry per domain. The server resumes a
7
+ // client by skipping event groups in each listed domain until it passes
8
+ // the given sequence number from the given server id; domains not listed
9
+ // are streamed from the beginning.
10
+ class MariadbGtidPosition {
11
+ constructor() {
12
+ // domainId (number) -> { domainId, serverId, seqNo }; seqNo is a
13
+ // BigInt so the full u64 sequence range stays exact
14
+ this._domains = new Map();
15
+ }
16
+
17
+ // Accepts '' or 'D-S-N[,D-S-N...]' (whitespace tolerated). Rejects two
18
+ // entries for one domain, as the server would (ER_DUPLICATE_GTID_DOMAIN)
19
+ static parse(text) {
20
+ const position = new MariadbGtidPosition();
21
+ if (!text) {
22
+ return position;
23
+ }
24
+ for (const entry of String(text).split(',')) {
25
+ const { domainId, serverId, seqNo } = parseSingleGtid(entry);
26
+ if (position._domains.has(domainId)) {
27
+ throw new Error(
28
+ `Duplicate replication domain ${domainId} in GTID position ` +
29
+ `'${text}'`);
30
+ }
31
+ position._domains.set(domainId, { domainId, serverId, seqNo });
32
+ }
33
+ return position;
34
+ }
35
+
36
+ // Builds the position implied by a GTID_LIST_EVENT: the event may hold
37
+ // several entries per domain (one per server id); the last entry of a
38
+ // domain's run is the most recent, so plain per-domain overwrite in
39
+ // event order yields the right watermark
40
+ static fromGtidList(entries) {
41
+ const position = new MariadbGtidPosition();
42
+ for (const entry of entries) {
43
+ position.add(entry);
44
+ }
45
+ return position;
46
+ }
47
+
48
+ // Records { domainId, serverId, seqNo } as the last transaction
49
+ // processed in its domain. Deliberately an overwrite, not a max: after
50
+ // a failover within a domain the newest transaction can carry a lower
51
+ // sequence number under a different server id, and "last processed" is
52
+ // what the server's skip rule keys on
53
+ add(gtid) {
54
+ this._domains.set(gtid.domainId, {
55
+ domainId: gtid.domainId,
56
+ serverId: gtid.serverId,
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),
60
+ });
61
+ }
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
+
97
+ isEmpty() {
98
+ return this._domains.size === 0;
99
+ }
100
+
101
+ toString() {
102
+ return [...this._domains.values()]
103
+ .sort((a, b) => a.domainId - b.domainId)
104
+ .map(({ domainId, serverId, seqNo }) =>
105
+ `${domainId}-${serverId}-${seqNo}`)
106
+ .join(',');
107
+ }
108
+ }
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
+
123
+ function parseUInt32(text, context) {
124
+ // Decimal digits only: Number() alone would also accept '1e3' or '0x10'
125
+ if (!/^\d+$/.test(text)) {
126
+ throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
127
+ }
128
+ const value = Number(text);
129
+ if (!Number.isSafeInteger(value) || value > 0xffffffff) {
130
+ throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
131
+ }
132
+ return value;
133
+ }
134
+
135
+ function parseSeqNo(text, context) {
136
+ // Decimal digits only: BigInt() alone would also accept '0x10' or '0b1'
137
+ if (!/^\d+$/.test(text)) {
138
+ throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
139
+ }
140
+ // seq_no is u64; BigInt keeps it exact beyond 2^53
141
+ const value = BigInt(text);
142
+ if (value > 0xffffffffffffffffn) {
143
+ throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
144
+ }
145
+ return value;
146
+ }
147
+
148
+ export { MariadbGtidPosition };
@@ -17,7 +17,7 @@ export default function initBinlogPacketClass(zongji) {
17
17
 
18
18
  const timestamp = parser.parseUnsignedNumber(4) * 1000;
19
19
  const eventType = parser.parseUnsignedNumber(1);
20
- parser.parseUnsignedNumber(4); // serverId
20
+ const serverId = parser.parseUnsignedNumber(4);
21
21
  const eventLength = parser.parseUnsignedNumber(4);
22
22
  const nextPosition = parser.parseUnsignedNumber(4);
23
23
  parser.parseUnsignedNumber(2); // flags
@@ -27,7 +27,13 @@ export default function initBinlogPacketClass(zongji) {
27
27
  nextPosition: nextPosition,
28
28
  size: eventLength - BinlogPacket.Length,
29
29
  eventType: eventType,
30
+ // MariaDB GTID events need it: the GTID's server component lives
31
+ // in the common header
32
+ serverId: serverId,
30
33
  };
34
+ // Exposed for bookkeeping on events that are filtered out before
35
+ // being parsed (the event object never exists for those)
36
+ this.nextPosition = nextPosition;
31
37
 
32
38
  const EventClass = getEventClass(eventType);
33
39
  this.eventName = EventClass.name;
package/lib/rows_event.js CHANGED
@@ -1,10 +1,26 @@
1
1
  import { BinlogEvent } from './binlog_event.js';
2
2
  import * as Common from './common.js';
3
+ import { Parser } from './reader.js';
3
4
 
4
5
  const Version2Events = [
5
6
  0x1e, // WRITE_ROWS_EVENT_V2,
6
7
  0x1f, // UPDATE_ROWS_EVENT_V2,
7
8
  0x20, // DELETE_ROWS_EVENT_V2
9
+ 0xa9, // WRITE_ROWS_COMPRESSED_EVENT (MariaDB, v2 layout)
10
+ 0xaa, // UPDATE_ROWS_COMPRESSED_EVENT
11
+ 0xab, // DELETE_ROWS_COMPRESSED_EVENT
12
+ ];
13
+
14
+ // MariaDB log_bin_compress=ON variants: identical to the corresponding
15
+ // plain event through the columns-present bitmap(s); the row-image area
16
+ // (from the first row's null bitmap) is a compressed-payload envelope
17
+ const CompressedEvents = [
18
+ 0xa6, // WRITE_ROWS_COMPRESSED_EVENT_V1
19
+ 0xa7, // UPDATE_ROWS_COMPRESSED_EVENT_V1
20
+ 0xa8, // DELETE_ROWS_COMPRESSED_EVENT_V1
21
+ 0xa9, // WRITE_ROWS_COMPRESSED_EVENT
22
+ 0xaa, // UPDATE_ROWS_COMPRESSED_EVENT
23
+ 0xab, // DELETE_ROWS_COMPRESSED_EVENT
8
24
  ];
9
25
 
10
26
  const CHECKSUM_SIZE = 4;
@@ -72,20 +88,36 @@ class RowsEvent extends BinlogEvent {
72
88
  this.columns_present_bitmap2 = parser.parseBuffer(columnsPresentBitmapSize);
73
89
  }
74
90
 
75
- if (this.useChecksum) {
91
+ let rowsParser = parser;
92
+ let trimChecksum = this.useChecksum;
93
+ if (CompressedEvents.indexOf(options.eventType) !== -1) {
94
+ // Inflate the row-image area and read the rows from the inflated
95
+ // bytes; the event's CRC covers the compressed bytes and stays on
96
+ // the original parser
97
+ const end =
98
+ parser._packetEnd - (this.useChecksum ? CHECKSUM_SIZE : 0);
99
+ const inflated = Common.uncompressBinlogEventPayload(
100
+ parser.parseBuffer(end - parser._offset));
101
+ parser._offset = parser._packetEnd;
102
+ rowsParser = new Parser();
103
+ rowsParser.append(inflated);
104
+ trimChecksum = false;
105
+ }
106
+
107
+ if (trimChecksum) {
76
108
  // Ignore the checksum at the end of this packet
77
- parser._packetEnd -= CHECKSUM_SIZE;
109
+ rowsParser._packetEnd -= CHECKSUM_SIZE;
78
110
  }
79
111
 
80
112
  this.rows = [];
81
- while (!parser.reachedPacketEnd()) {
82
- this.rows.push(this._fetchOneRow(parser));
113
+ while (!rowsParser.reachedPacketEnd()) {
114
+ this.rows.push(this._fetchOneRow(rowsParser));
83
115
  }
84
116
 
85
- if (this.useChecksum) {
117
+ if (trimChecksum) {
86
118
  // Skip past the checksum at the end of the packet
87
- parser._packetEnd += CHECKSUM_SIZE;
88
- parser._offset += CHECKSUM_SIZE;
119
+ rowsParser._packetEnd += CHECKSUM_SIZE;
120
+ rowsParser._offset += CHECKSUM_SIZE;
89
121
  }
90
122
  }
91
123
  }