@vlasky/zongji 0.6.1 → 0.7.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.
@@ -21,11 +21,62 @@ const JSONB_LITERALS = [ null, true, false ];
21
21
  // node-mysql prefixes binary string values
22
22
  const VAR_STRING_PREFIX = 'base64:type253:';
23
23
 
24
+ // Decodes a JSONB binary buffer into JavaScript values (object, array,
25
+ // string, number, boolean, BigInt for 64-bit integers, or null). The
26
+ // caller converts BigInts and decides whether to serialise back to a JSON
27
+ // string (mysql2 `jsonStrings` option) via the helpers below.
24
28
  export default function decodeJson(input) {
25
- // Value must be JSON string to match node-mysql results
26
- // Related to this node-mysql PR:
27
- // https://github.com/felixge/node-mysql/pull/1299
28
- return JSON.stringify(parseBinaryBuffer(input));
29
+ return parseBinaryBuffer(input);
30
+ }
31
+
32
+ const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
33
+ const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
34
+
35
+ // For parsed-value output: BigInts become Numbers when within the safe
36
+ // integer range, exact strings otherwise. Mutates objects/arrays in place
37
+ // (they were freshly created by parseBinaryBuffer).
38
+ export function convertBigInts(value) {
39
+ if (typeof value === 'bigint') {
40
+ return (value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT) ?
41
+ Number(value) : value.toString();
42
+ }
43
+ if (Array.isArray(value)) {
44
+ for (let i = 0; i < value.length; i++) {
45
+ value[i] = convertBigInts(value[i]);
46
+ }
47
+ return value;
48
+ }
49
+ if (value !== null && typeof value === 'object') {
50
+ for (const key of Object.keys(value)) {
51
+ value[key] = convertBigInts(value[key]);
52
+ }
53
+ return value;
54
+ }
55
+ return value;
56
+ }
57
+
58
+ // For jsonStrings output: serialise to JSON text with 64-bit integers as
59
+ // raw numerals (JSON.stringify cannot represent BigInt), so the text
60
+ // matches what the server would print for the column.
61
+ export function jsonToText(value) {
62
+ if (value === null) {
63
+ return 'null';
64
+ }
65
+ switch (typeof value) {
66
+ case 'bigint':
67
+ case 'number':
68
+ case 'boolean':
69
+ return String(value);
70
+ case 'string':
71
+ return JSON.stringify(value);
72
+ default:
73
+ if (Array.isArray(value)) {
74
+ return '[' + value.map(jsonToText).join(', ') + ']';
75
+ }
76
+ return '{' + Object.keys(value).map(key =>
77
+ JSON.stringify(key) + ': ' + jsonToText(value[key])
78
+ ).join(', ') + '}';
79
+ }
29
80
  }
30
81
 
31
82
  /*
@@ -112,25 +163,17 @@ function parseBinaryBuffer(input, offset, parentValueOffset, readUInt, isLarge)
112
163
  result = isLarge ? input.readUInt32LE(offset + 1) : input.readUInt32LE(valueOffset + 1);
113
164
  break;
114
165
  case JSONB_TYPE_INT64: {
115
- let low = input.readUInt32LE(valueOffset + 1);
116
- let high = input.readUInt32LE(valueOffset + 5);
117
- if (high & (1 << 31)) {
118
- // Javascript integers only support 2^53 not 2^64, must trim bits!
119
- // 64-53 = 11, 32-11 = 21, so grab first 21 bits of high word only
120
- const mask = Math.pow(2, 32) - 1;
121
- high = common.sliceBits(high ^ mask, 0, 21);
122
- low = low ^ mask;
123
- result =
124
- ((high * Math.pow(2, 32)) * - 1) - common.getUInt32Value(low) - 1;
125
- } else {
126
- result = (high * Math.pow(2,32)) + low;
127
- }
166
+ const low = input.readUInt32LE(valueOffset + 1);
167
+ const high = input.readUInt32LE(valueOffset + 5);
168
+ // Exact; converted to Number / string / raw JSON numeral by the
169
+ // consumers below
170
+ result = BigInt.asIntN(64, (BigInt(high) << 32n) | BigInt(low));
128
171
  break;
129
172
  }
130
173
  case JSONB_TYPE_UINT64: {
131
174
  const low = input.readUInt32LE(valueOffset + 1);
132
175
  const high = input.readUInt32LE(valueOffset + 5);
133
- result = (high * Math.pow(2,32)) + low;
176
+ result = (BigInt(high) << 32n) | BigInt(low);
134
177
  break;
135
178
  }
136
179
  case JSONB_TYPE_OPAQUE: {
@@ -32,6 +32,13 @@ export default function initBinlogPacketClass(zongji) {
32
32
  const EventClass = getEventClass(eventType);
33
33
  this.eventName = EventClass.name;
34
34
 
35
+ // Warn (once per instance) about event types that carry row changes
36
+ // zongji cannot decode. This must happen before event filtering so
37
+ // the data loss is loud even when the type is not included.
38
+ if (EventClass.unsupportedReason) {
39
+ zongji._warnUnsupportedEvent(EventClass);
40
+ }
41
+
35
42
  yield;
36
43
 
37
44
  try {
package/lib/reader.js CHANGED
@@ -1,145 +1,6 @@
1
- // Constants for variable length encoded binary
2
- export const NULL_COLUMN = 251;
3
- export const UNSIGNED_CHAR_COLUMN = 251;
4
- export const UNSIGNED_SHORT_COLUMN = 252;
5
- export const UNSIGNED_INT24_COLUMN = 253;
6
- export const UNSIGNED_INT64_COLUMN = 254;
7
-
8
- const padWith = function(val, length) {
9
- const bits = val.split('');
10
- if (bits.length < length) {
11
- const left = length - bits.length;
12
- for (let j = left - 1; j >= 0; j--) {
13
- bits.unshift('0');
14
- }
15
- val = bits.join('');
16
- }
17
-
18
- return val;
19
- };
20
-
21
- class BufferReader {
22
- constructor(buffer) {
23
- this.buffer = buffer;
24
- this.position = 0;
25
- }
26
-
27
- readUInt8() {
28
- const pos = this.position;
29
- this.position += 1;
30
-
31
- return this.buffer.readUInt8(pos);
32
- }
33
-
34
- readUInt16() {
35
- const pos = this.position;
36
- this.position += 2;
37
-
38
- return this.buffer.readUInt16LE(pos);
39
- }
40
-
41
- readUInt32() {
42
- const pos = this.position;
43
- this.position += 4;
44
-
45
- return this.buffer.readUInt32LE(pos);
46
- }
47
-
48
- readUInt24() {
49
- const low = this.readUInt16();
50
- const high = this.readUInt8();
51
- return (high << 16) + low;
52
- }
53
-
54
- readUInt64() {
55
- const pos = this.position;
56
- this.position += 8;
57
-
58
- // from http://stackoverflow.com/questions/17687307/convert-a-64bit-little-endian-integer-to-number
59
- return this.buffer.readInt32LE(pos) +
60
- 0x100000000 * this.buffer.readUInt32LE(pos + 4);
61
- }
62
-
63
- readString() {
64
- const strBuf = this.buffer.slice(this.position);
65
- this.position = this.buffer.length;
66
-
67
- return strBuf.toString('ascii');
68
- }
69
-
70
- readStringInBytes(length) {
71
- const strBuf = this.buffer.slice(this.position, this.position + length);
72
- this.position += length;
73
-
74
- return strBuf.toString('ascii');
75
- }
76
-
77
- readHexInBytes(length) {
78
- const buf = this.buffer.slice(this.position, this.position + length);
79
- this.position += length;
80
-
81
- return buf.toString('hex');
82
- }
83
-
84
- readBytesArray(length) {
85
- const result = [];
86
- const hexString = this.readHexInBytes(length);
87
- for (let i = 0; i < hexString.length; i = i + 2) {
88
- result.push(parseInt(hexString.substr(i, 2), 16));
89
- }
90
- return result;
91
- }
92
-
93
- // Read a variable-length "Length Coded Binary" integer. This is derived
94
- // from the MySQL protocol, and re-used in the binary log format. This
95
- // format uses the first byte to alternately store the actual value for
96
- // integer values <= 250, or to encode the number of following bytes
97
- // used to store the actual value, which can be 2, 3, or 8. It also
98
- // includes support for SQL NULL as a special case.
99
- readVariant() {
100
- let result = null;
101
- const firstByte = this.readUInt8();
102
-
103
- if (firstByte < UNSIGNED_CHAR_COLUMN) {
104
- result = firstByte;
105
- } else if (firstByte === NULL_COLUMN) {
106
- result = null;
107
- } else if (firstByte === UNSIGNED_SHORT_COLUMN) {
108
- result = this.readUInt16();
109
- } else if (firstByte === UNSIGNED_INT24_COLUMN) {
110
- result = this.readUInt24();
111
- } else if (firstByte === UNSIGNED_INT64_COLUMN) {
112
- result = this.readUInt64();
113
- } else {
114
- throw new Error('Invalid variable-length integer');
115
- }
116
-
117
- return result;
118
- }
119
-
120
- // Read an arbitrary-length bitmap, provided its length.
121
- // Returns an array of true/false values.
122
- readBitArray(length) {
123
- const size = Math.floor((length + 7) / 8);
124
-
125
- const bytes = [];
126
- for (let i = size - 1; i >= 0; i--) {
127
- bytes.unshift(this.readUInt8());
128
- }
129
-
130
- const bitmap = [];
131
- const bitmapStr = bytes.map(function(aByte) {
132
- return padWith(aByte.toString(2), 8);
133
- }).join('');
134
-
135
- for (let k = bitmapStr.length - 1; k >= 0; k--) {
136
- bitmap.push(bitmapStr[k] === '1');
137
- }
138
-
139
- return bitmap.slice(0, length);
140
- }
141
- }
142
-
1
+ // Minimal parser over a mysql2 packet (or a bare buffer via append()),
2
+ // providing the subset of the node-mysql Parser interface that the binlog
3
+ // event classes consume.
143
4
  class Parser {
144
5
  constructor(packet) {
145
6
  this.packet = null;
@@ -244,4 +105,4 @@ class Parser {
244
105
  }
245
106
  }
246
107
 
247
- export { BufferReader, Parser };
108
+ export { Parser };
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'events';
2
- import { initBinlogPacketClass } from '../packet/index.js';
2
+ import initBinlogPacketClass from '../packet/binlog.js';
3
3
  import { Parser } from '../reader.js';
4
4
 
5
5
  const BINLOG_DUMP_COMMAND = 0x12;
@@ -137,16 +137,38 @@ export default function initBinlogClass(zongji) {
137
137
  const eventName = binlogPacket.eventName
138
138
  ? binlogPacket.eventName.toLowerCase()
139
139
  : 'unknown';
140
+
141
+ let event;
142
+ let error;
143
+
144
+ // Track the current transaction GTID before event filtering, so
145
+ // subsequent events can carry event.gtid even when 'gtid' events
146
+ // themselves are excluded by includeEvents
147
+ const isGtidEvent =
148
+ eventName === 'gtid' || eventName === 'anonymousgtid';
149
+ if (isGtidEvent) {
150
+ try {
151
+ event = binlogPacket.getEvent();
152
+ // Anonymous transactions have no usable GTID; do not let a
153
+ // previous transaction's GTID leak onto their events
154
+ zongji._currentGtid =
155
+ eventName === 'gtid' ? event.gtid : undefined;
156
+ } catch (err) {
157
+ zongji._currentGtid = undefined;
158
+ error = err;
159
+ }
160
+ }
161
+
140
162
  if (zongji._skipEvent(eventName)) {
141
163
  return Binlog.prototype.binlogData;
142
164
  }
143
165
 
144
- let event;
145
- let error;
146
- try {
147
- event = binlogPacket.getEvent();
148
- } catch (err) {
149
- error = err;
166
+ if (!isGtidEvent && !error) {
167
+ try {
168
+ event = binlogPacket.getEvent();
169
+ } catch (err) {
170
+ error = err;
171
+ }
150
172
  }
151
173
  this._callback.call(this, error, event);
152
174
  }
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "@vlasky/zongji",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "MySQL binlog-based change data capture (CDC) for Node.js",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
- "directories": {
9
- "test": "test"
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "pretest": "bash scripts/start-mysql.sh",
13
16
  "test": "tap --bail --disable-coverage --jobs=1 test/*.js",
17
+ "test:only": "tap --bail --disable-coverage --jobs=1 test/*.js",
14
18
  "lint": "eslint .",
15
19
  "checkjs": "tsc --project jsconfig.json"
16
20
  },
@@ -42,8 +46,7 @@
42
46
  "tap": "^21.5.0"
43
47
  },
44
48
  "dependencies": {
45
- "big-integer": "1.6.52",
46
49
  "iconv-lite": "0.7.2",
47
- "mysql2": "^3.17.1"
50
+ "mysql2": "^3.22.5"
48
51
  }
49
52
  }
package/.travis.yml DELETED
@@ -1,15 +0,0 @@
1
- language: node_js
2
- sudo: required
3
- dist: trusty
4
- node_js:
5
- - "8"
6
- - "12"
7
- services:
8
- - docker
9
- before_script:
10
- - npm run lint
11
- - docker run -p 3355:3306 --name mysql-55 -e MYSQL_ROOT_PASSWORD=numtel -d mysql:5.5 --server-id=1 --log-bin=/var/lib/mysql/mysql-bin.log --binlog-format=row
12
- - docker run -p 3356:3306 --name mysql-56 -e MYSQL_ROOT_PASSWORD=numtel -d mysql:5.6 --server-id=1 --log-bin=/var/lib/mysql/mysql-bin.log --binlog-format=row
13
- - docker run -p 3357:3306 --name mysql-57 -e MYSQL_ROOT_PASSWORD=numtel -d mysql:5.7 --server-id=1 --log-bin=/var/lib/mysql/mysql-bin.log --binlog-format=row
14
- script:
15
- - ./test/travis/runner.sh
@@ -1,33 +0,0 @@
1
- ---
2
- services:
3
- mysql57:
4
- image: mysql:5.7
5
- command: [ "--server-id=1", "--log-bin=/var/lib/mysql/mysql-bin.log", "--binlog-format=row"]
6
- networks:
7
- default:
8
- aliases:
9
- - mysql57
10
- environment:
11
- MYSQL_ROOT_PASSWORD: secret
12
-
13
- mysql80:
14
- image: mysql:8.0
15
- command: [ "--server-id=1", "--log-bin=/var/lib/mysql/mysql-bin.log", "--binlog-format=row"]
16
- networks:
17
- default:
18
- aliases:
19
- - mysql80
20
- environment:
21
- MYSQL_ROOT_PASSWORD: secret
22
-
23
- mysql84:
24
- image: mysql:8.4
25
- command: [ "--server-id=1", "--log-bin=/var/lib/mysql/mysql-bin.log", "--binlog-format=row"]
26
- ports:
27
- - "3306:3306"
28
- networks:
29
- default:
30
- aliases:
31
- - mysql84
32
- environment:
33
- MYSQL_ROOT_PASSWORD: secret
package/docker-test.sh DELETED
@@ -1,11 +0,0 @@
1
- #!/bin/bash
2
- MYSQL_HOSTS="mysql57 mysql80 mysql84"
3
-
4
- for hostname in ${MYSQL_HOSTS}; do
5
- echo $hostname + node 18
6
- docker run -it --network=zongji_default -e MYSQL_HOST=$hostname -w /build -v $PWD:/build node:18 npm test
7
- echo $hostname + node 20
8
- docker run -it --network=zongji_default -e MYSQL_HOST=$hostname -w /build -v $PWD:/build node:20 npm test
9
- echo $hostname + node 22
10
- docker run -it --network=zongji_default -e MYSQL_HOST=$hostname -w /build -v $PWD:/build node:22 npm test
11
- done
package/eslint.config.js DELETED
@@ -1,35 +0,0 @@
1
- import js from '@eslint/js';
2
-
3
- export default [
4
- { ignores: ['.tap/'] },
5
- js.configs.recommended,
6
- {
7
- languageOptions: {
8
- ecmaVersion: 2022,
9
- sourceType: 'module',
10
- globals: {
11
- Buffer: 'readonly',
12
- clearImmediate: 'readonly',
13
- clearInterval: 'readonly',
14
- clearTimeout: 'readonly',
15
- console: 'readonly',
16
- process: 'readonly',
17
- setImmediate: 'readonly',
18
- setInterval: 'readonly',
19
- setTimeout: 'readonly',
20
- },
21
- },
22
- rules: {
23
- 'comma-dangle': ['warn', 'only-multiline'],
24
- 'eol-last': ['error'],
25
- 'keyword-spacing': ['error', { before: true }],
26
- 'no-console': 'off',
27
- 'no-trailing-spaces': ['error', { skipBlankLines: true }],
28
- 'no-unused-vars': 'warn',
29
- 'no-var': 'warn',
30
- 'quotes': ['warn', 'single', 'avoid-escape'],
31
- 'semi': ['error', 'always'],
32
- 'space-before-blocks': 'error',
33
- },
34
- },
35
- ];
package/jsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "checkJs": true,
4
- "allowJs": true,
5
- "noEmit": true,
6
- "target": "ES2022",
7
- "module": "NodeNext",
8
- "moduleResolution": "NodeNext",
9
- "skipDefaultLibCheck": true,
10
- "maxNodeModuleJsDepth": 0,
11
- "types": ["node"]
12
- },
13
- "include": ["index.js", "index.d.ts", "lib/**/*.js"],
14
- "exclude": ["node_modules", ".tap"]
15
- }
@@ -1,27 +0,0 @@
1
- class ComBinlog {
2
- constructor({ serverId, nonBlock, filename, position }) {
3
- this.command = 0x12;
4
- this.position = position || 4;
5
-
6
- // will send eof package if there is no more binlog event
7
- // https://dev.mysql.com/doc/internals/en/com-binlog-dump.html#binlog-dump-non-block
8
- this.flags = nonBlock ? 1 : 0;
9
-
10
- this.serverId = serverId || 1;
11
- this.filename = filename || '';
12
- }
13
-
14
- write(writer) {
15
- writer.writeUnsignedNumber(1, this.command);
16
- writer.writeUnsignedNumber(4, this.position);
17
- writer.writeUnsignedNumber(2, this.flags);
18
- writer.writeUnsignedNumber(4, this.serverId);
19
- writer.writeNullTerminatedString(this.filename);
20
- }
21
-
22
- parse() {
23
- throw new Error('should never be called here');
24
- }
25
- }
26
-
27
- export default ComBinlog;
@@ -1,66 +0,0 @@
1
- import ComBinlog from './combinlog.js';
2
- import initBinlogPacketClass from './binlog.js';
3
-
4
- // from npm package mysql
5
- class EofPacket {
6
- constructor(options = {}) {
7
- this.fieldCount = undefined;
8
- this.warningCount = options.warningCount;
9
- this.serverStatus = options.serverStatus;
10
- this.protocol41 = options.protocol41;
11
- }
12
-
13
- parse(parser) {
14
- this.fieldCount = parser.parseUnsignedNumber(1);
15
- if (this.protocol41) {
16
- this.warningCount = parser.parseUnsignedNumber(2);
17
- this.serverStatus = parser.parseUnsignedNumber(2);
18
- }
19
- }
20
-
21
- write(writer) {
22
- writer.writeUnsignedNumber(1, 0xfe);
23
- if (this.protocol41) {
24
- writer.writeUnsignedNumber(2, this.warningCount);
25
- writer.writeUnsignedNumber(2, this.serverStatus);
26
- }
27
- }
28
- }
29
-
30
- // from npm package mysql
31
- class ErrorPacket {
32
- constructor(options = {}) {
33
- this.fieldCount = options.fieldCount;
34
- this.errno = options.errno;
35
- this.sqlStateMarker = options.sqlStateMarker;
36
- this.sqlState = options.sqlState;
37
- this.message = options.message;
38
- }
39
-
40
- parse(parser) {
41
- this.fieldCount = parser.parseUnsignedNumber(1);
42
- this.errno = parser.parseUnsignedNumber(2);
43
-
44
- // sqlStateMarker ('#' = 0x23) indicates error packet format
45
- if (parser.peak() === 0x23) {
46
- this.sqlStateMarker = parser.parseString(1);
47
- this.sqlState = parser.parseString(5);
48
- }
49
-
50
- this.message = parser.parsePacketTerminatedString();
51
- }
52
-
53
- write(writer) {
54
- writer.writeUnsignedNumber(1, 0xff);
55
- writer.writeUnsignedNumber(2, this.errno);
56
-
57
- if (this.sqlStateMarker) {
58
- writer.writeString(this.sqlStateMarker);
59
- writer.writeString(this.sqlState);
60
- }
61
-
62
- writer.writeString(this.message);
63
- }
64
- }
65
-
66
- export { EofPacket, ErrorPacket, ComBinlog, initBinlogPacketClass };
@@ -1,28 +0,0 @@
1
- #!/bin/bash
2
- # Start MySQL Docker containers for testing
3
- # Skips MySQL 5.7 on ARM64 (Apple Silicon) as no image is available
4
-
5
- if [ "$(uname -m)" = "arm64" ]; then
6
- SERVICES="mysql80 mysql84"
7
- else
8
- SERVICES="mysql57 mysql80 mysql84"
9
- fi
10
-
11
- docker-compose up -d $SERVICES
12
-
13
- # Wait for MySQL to be ready
14
- for service in $SERVICES; do
15
- container="zongji-${service}-1"
16
- echo -n "Waiting for ${service}..."
17
- for i in $(seq 1 30); do
18
- if docker exec "$container" mysqladmin ping -u root -psecret --silent 2>/dev/null; then
19
- echo " ready"
20
- break
21
- fi
22
- if [ "$i" -eq 30 ]; then
23
- echo " timed out"
24
- exit 1
25
- fi
26
- sleep 2
27
- done
28
- done