@vlasky/zongji 0.7.1 → 0.8.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,20 @@
2
2
 
3
3
  All notable changes to @vlasky/zongji since forking from nevill/zongji.
4
4
 
5
+ ## [0.8.0] - 2026-07-06
6
+
7
+ - 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.
8
+ - Close the remaining resume-position gap for multi-table statements (multi-table `UPDATE`, foreign-key cascades). The server writes all of a statement's TableMap events before any row event, so a `filename`/`position` pair persisted while the first table's rows were being processed pointed past the later TableMaps; a consumer resuming there dropped the remaining tables' rows with no error. The resume position now freezes at a transaction's first TableMap and advances to the commit marker's end position, so a persisted position always replays whole transactions. Behaviour change: for row-logged transactions the position advances per transaction rather than per event, so resuming after a mid-transaction crash re-delivers that transaction's earlier row events (at-least-once, as already documented for the single-table case fixed in 0.7.1). Statement-logged content (`binlog_format=STATEMENT`/`MIXED`) still advances per event; its replay has no table-metadata dependency, so it never had the dropped-rows hazard.
9
+ - Tagged GTID support (MySQL 8.3+, `GTID_NEXT='AUTOMATIC:tag'`). Tagged transactions arrive as GTID_TAGGED_LOG_EVENT (code 42, a self-describing serialization format unrelated to the classic layout, previously `unknown`) and now decode as ordinary `gtid` events with `event.tag` set and `event.gtid` in `uuid:tag:gno` form. GTID sets accept and print the tagged grammar (`uuid:1-5:tag_a:1`), `zongji.gtidSet` tracks tagged transactions, `start({ gtidSet })` resumes from a tagged checkpoint (COM_BINLOG_DUMP_GTID's tagged payload encoding, understood by MySQL 8.3+ only), and `startAtEnd` seeds from a `gtid_executed` containing tags. This is also a compatibility fix: once a MySQL 8.3+ server has ever executed a tagged GTID it writes every Previous_gtids event in the tagged set encoding forever (surviving `RESET BINARY LOGS AND GTIDS`), which previously crashed parsing at the start of every dump, and any tag in `gtid_executed` broke `startAtEnd` seeding.
10
+ - New `rowsquery` event (MySQL ROWS_QUERY_LOG_EVENT, previously `unknown`): the SQL statement text behind the row events that follow, MySQL's analogue of MariaDB's `annotaterows`. The server sends it to every consumer while `binlog_rows_query_log_events=ON` (default `OFF`); no start option is involved, unlike MariaDB's opt-in `requestAnnotateRows`.
11
+ - `event.gtid` no longer leaks onto events that belong to no transaction: it is now cleared once a commit marker (Xid, XA Prepare, or a COMMIT/ROLLBACK/XA COMMIT/XA ROLLBACK query) has been delivered, so e.g. a rotate arriving between two transactions reports `gtid: undefined` instead of the previous transaction's GTID. The commit event itself still carries its transaction's GTID. A DDL statement has no commit marker of its own, so its GTID persists until the next transaction begins. New `zongji.lastGtid` getter exposes the same tracked value (the GTID of the transaction currently being delivered); it is only coherent read synchronously inside a `'binlog'` handler.
12
+ - Fix text CHAR and VARCHAR columns always decoding as utf8: they now decode via the column's own character set, as TEXT columns already did, so e.g. `latin1` and `greek` columns no longer produce mojibake. In the same pass, charsets whose stored byte order differs from the iconv-lite encoding of the same name are now mapped explicitly: MySQL's `latin1` is decoded as cp1252 (its real definition; `€`, `Ÿ` and friends in 0x80-0x9F previously came back as control characters from TEXT columns), and `ucs2`/`utf16`/`utf32` are decoded big-endian (previously byte-swapped garbage from TEXT columns). Behaviour change: consumers that stored the old garbled output will see correct strings after upgrading.
13
+ - MariaDB support (tested against MariaDB 11.8): ZongJi now detects the server flavour and follows MariaDB binlogs natively, announcing `@mariadb_slave_capability=4` so the server sends its real GTID events instead of rewriting them for legacy clients. MariaDB's GTID model (a `domain-server-sequence` watermark per replication domain) is fully supported: events carry `event.gtid` in MariaDB format, `zongji.gtidSet` exposes a MariaDB GTID position (seeded from `start({ gtidSet })`, from `@@gtid_current_pos` under `startAtEnd`, or from the GTID list event at the start of a binlog file), and `start({ gtidSet: '0-1-1234' })` resumes via `@slave_connect_state` with server-side skipping, including across failover. New event types: `mariadbgtid`, `mariadbgtidlist`, `binlogcheckpoint`, `startencryption`, plus `xaprepare` (also emitted by MySQL 5.7+, previously `unknown`). MariaDB data types decode to match mysql2 query results: `UUID`, `INET4` and `INET6` as canonical text, `VECTOR` as a raw Buffer, `JSON` as the LONGTEXT alias text it is, `COMPRESSED` columns transparently decompressed (with correct charsets, under all `binlog_row_metadata` settings), MariaDB 5.3-era "hires" temporals (`mysql56_temporal_format=OFF`) decoded correctly instead of desynchronising the row, and compressed binlog events (`log_bin_compress=ON`) decoded transparently as ordinary query/row events. `binlog_row_metadata=FULL` works on MariaDB 10.5+ with the same self-describing decode as MySQL; tables whose classic temporal columns could hide hires encodings automatically use `INFORMATION_SCHEMA` instead, and `UUID`/`INET*`/`VECTOR` values arrive as raw Buffers in FULL mode (the binlog cannot distinguish them from `BINARY`). An opt-in `requestAnnotateRows` start option asks the server for `annotaterows` events carrying the SQL statement text behind each row operation. The test suite runs against MariaDB 11.8 in CI alongside MySQL 5.7/8.0/8.4, and an offline MariaDB fixture pins the captured wire bytes of all of the above in the parser tests.
14
+ - Fix the resume position (`options.position`) going stale when the events carrying real positions are excluded by `includeEvents`: filtered events now advance it at the packet layer under the same safety rules as delivered ones (never past a TableMap, never a zero position). On MySQL the position merely lagged; on MariaDB, where events inside a transaction carry `end_log_pos=0`, common filter sets froze it entirely.
15
+ - Fix a filtered `rotate` event leaving an incoherent resume pair: the filename never updated while later events advanced the position into the new file. Rotates now update the `filename`/`position` pair before event filtering, whether delivered or not.
16
+ - GTID-based resume: `start({ gtidSet })` issues COM_BINLOG_DUMP_GTID, letting the server locate the correct binlog file and skip already-processed transactions itself; a persisted checkpoint therefore survives failover to another server in the same replication topology (requires `gtid_mode=ON`). `zongji.gtidSet` exposes the executed set for persisting: seeded exactly from the start set, from the server's `gtid_executed` under `startAtEnd`, or from the stream's Previous_gtids event when reading from the start of a binlog file, and extended only as observed transactions commit. Purged-GTID and GTID-mode errors surface through the `error` event. Heartbeat events (sent in place of server-side-skipped transactions and while idle) are now decoded instead of falling through to `unknown`, and Previous_gtids string formatting is canonical (single transactions print as `8`, not `8-8`)
17
+ - Support `binlog_row_metadata=FULL` (MySQL 8.0+): TableMap events now parse the optional metadata block (column names, signedness, character sets, enum/set value lists, primary key, column visibility), and when it is complete ZongJi decodes rows entirely from the binlog stream with no `INFORMATION_SCHEMA` queries and no connection pauses. Each TableMap event rebuilds the table's metadata, so `ALTER TABLE` can no longer leave stale column definitions behind. Enum/set values containing commas or quotes decode correctly in this mode (the `INFORMATION_SCHEMA` path cannot represent them). Under the default `binlog_row_metadata=MINIMAL`, integer signedness now comes from the binlog instead of being inferred from the `COLUMN_TYPE` string. TableMap events expose `columnNames`, `signedness`, `primaryKey` and `columnVisibility` where available, and column schemas gain `UNSIGNED`, `ENUM_VALUES` and `SET_VALUES`.
18
+
5
19
  ## [0.7.1] - 2026-07-04
6
20
 
7
21
  - Fix `start()` calls made while a previous `start()` was still initialising silently discarding their filters. Since 0.7.0 filters are snapshotted and re-calling `start()` is the documented way to update them, but updates made between `start()` and the `ready` event were lost; consumers registering tables during boot (e.g. @vlasky/mysql-live-select) missed events for tables added in that window. Filters passed during initialisation now apply, exactly as when already running; stream options (filename/position/serverId) still come from the first call.
package/README.md CHANGED
@@ -3,9 +3,9 @@
3
3
  [![node version](https://img.shields.io/node/v/@vlasky/zongji.svg)](https://www.npmjs.com/package/@vlasky/zongji)
4
4
  [![license](https://img.shields.io/npm/l/@vlasky/zongji.svg)](https://github.com/vlasky/zongji/blob/master/LICENSE)
5
5
 
6
- MySQL binlog-based change data capture (CDC) for Node.js, [originally created by Nevill Dutt](https://github.com/nevill/zongji).
6
+ MySQL and MariaDB binlog-based change data capture (CDC) for Node.js, [originally created by Nevill Dutt](https://github.com/nevill/zongji).
7
7
 
8
- [@vlasky/zongji](https://github.com/vlasky/zongji) has been tested working with MySQL 5.7, 8.0 and 8.4.
8
+ [@vlasky/zongji](https://github.com/vlasky/zongji) has been tested working with MySQL 5.7, 8.0 and 8.4, and MariaDB 11.8.
9
9
 
10
10
  It leverages [`mysql2`](https://github.com/sidorares/node-mysql2) for connections and authentication, while using zongji's binlog parsing and event pipeline.
11
11
 
@@ -72,8 +72,9 @@ const { default: ZongJi } = await import('@vlasky/zongji');
72
72
  $ npm install @vlasky/zongji
73
73
  ```
74
74
 
75
- * Enable MySQL binlog in `my.cnf`, restart MySQL server after making the changes.
76
- > Binlog checksum is enabled by default in all supported MySQL versions. Zongji can work with it, but it doesn't verify the checksum.
75
+ * Enable the binlog in `my.cnf`, restart the server after making the changes.
76
+ > Binlog checksum is enabled by default in all supported MySQL and MariaDB versions. Zongji can work with it, but it doesn't verify the checksum.
77
+ > On MariaDB, `binlog_format` defaults to `MIXED`, so setting it to `row` explicitly matters there in particular.
77
78
 
78
79
  ```
79
80
  # Must be unique integer from 1-2^32
@@ -87,6 +88,12 @@ const { default: ZongJi } = await import('@vlasky/zongji');
87
88
  binlog_do_db = employees # Optional, limit which databases to log
88
89
  expire_logs_days = 10 # Optional, purge old logs
89
90
  max_binlog_size = 100M # Optional, limit log size
91
+
92
+ # Recommended on MySQL 8.0+ / MariaDB 10.5+: makes binlog events carry
93
+ # complete column metadata (names, signedness, charsets, enum/set
94
+ # values), so ZongJi decodes rows without querying INFORMATION_SCHEMA
95
+ # (see below)
96
+ binlog_row_metadata = FULL
90
97
  ```
91
98
  * Create an account with replication privileges, e.g. given privileges to account `zongji` (or any account that you use to read binary logs)
92
99
 
@@ -112,6 +119,17 @@ Option | Effect on row values
112
119
  `decimalNumbers` | `DECIMAL` columns are returned as exact strings by default (e.g. `'-123.4500'`); set `decimalNumbers: true` to receive Numbers (may lose precision beyond 15 significant digits).
113
120
  `jsonStrings` | `JSON` columns are returned as parsed JavaScript values by default; set `jsonStrings: true` to receive JSON strings.
114
121
 
122
+ ### Table metadata source
123
+
124
+ To decode row events, ZongJi needs each table's column metadata. It obtains this in one of two ways:
125
+
126
+ * **From the binlog itself** (MySQL 8.0+ or MariaDB 10.5+ with `binlog_row_metadata=FULL`): every `TableMap` event carries column names, signedness, character sets and enum/set value lists. ZongJi decodes rows entirely from the stream: no `INFORMATION_SCHEMA` queries, no pausing the binlog connection, and no risk of the metadata being stale after an `ALTER TABLE` (each event describes the schema in force when it was written). Enum/set values containing commas or quotes also decode correctly only in this mode.
127
+ * **From `INFORMATION_SCHEMA`** (all other cases, including MySQL 5.7, the MySQL default `binlog_row_metadata=MINIMAL` and the MariaDB default `NO_LOG`): on the first event for each table, ZongJi briefly pauses the stream and fetches column metadata over its control connection. If the table is altered between the binlog write and the fetch, decoded rows may be misinterpreted until the next `TableMap` refresh.
128
+
129
+ Setting `binlog_row_metadata=FULL` on the server is therefore recommended. It is a dynamic global variable (`SET GLOBAL binlog_row_metadata=FULL`) with negligible binlog size overhead for typical schemas. Even under `MINIMAL`, MySQL 8.0+ binlogs carry exact integer signedness, which ZongJi uses in preference to parsing the column definition.
130
+
131
+ MariaDB caveats under `FULL`: the binlog cannot distinguish `UUID`, `INET4`, `INET6` and `VECTOR` columns from `BINARY`/`VARBINARY`, so in this mode their values arrive as raw `Buffer`s rather than the formatted values the `INFORMATION_SCHEMA` path produces; and tables containing classic temporal type codes (which may hide MariaDB 5.3-era "hires" columns the binlog cannot describe) automatically fall back to the `INFORMATION_SCHEMA` path.
132
+
115
133
  Each instance includes the following methods:
116
134
 
117
135
  Method Name | Arguments | Description
@@ -139,6 +157,8 @@ Option Name | Type | Description
139
157
  `startAtEnd` | `boolean` | Pass `true` to only emit binlog events that occur after ZongJi's instantiation. Must be used in `start()` method for effect.<br>**Default:** `false`
140
158
  `filename` | `string` | Begin reading events from this binlog file. If specified together with `position`, will take precedence over `startAtEnd`.
141
159
  `position` | `integer` | Begin reading events from this position. Must be included with `filename`.
160
+ `gtidSet` | `string` | Resume from executed GTIDs (e.g. a persisted `zongji.gtidSet`): a MySQL GTID set (`'uuid:1-27'`; tagged transactions print as `'uuid:1-27:tag_a:1'` on MySQL 8.3+) or a MariaDB GTID position (`'0-1-1234'`), matching the server flavour. The server locates the correct binlog file itself and skips transactions already processed, so a checkpoint remains valid across failover to another server in the same replication topology. Requires `gtid_mode=ON` on MySQL; always available on MariaDB. Pass `''` to stream the entire available history. Takes precedence over `filename`/`position` and `startAtEnd`.
161
+ `requestAnnotateRows` | `boolean` | MariaDB only: ask the server to send `annotaterows` events (the SQL statement text preceding each row operation's events). Off by default; note the statement text may contain sensitive literals that the row images alone would not expose. Ignored on MySQL.<br>**Default:** `false`
142
162
  `includeEvents` | `[string]` | Array of event names to include<br>**Example:** `['writerows', 'updaterows', 'deleterows']`
143
163
  `excludeEvents` | `[string]` | Array of event names to exclude<br>**Example:** `['rotate', 'tablemap']`
144
164
  `includeSchema` | `object` | Object describing which databases and tables to include (Only for row events). Use database names as the key and pass an array of table names or `true` (for the entire database).<br>**Example:** ```{ 'my_database': ['allow_table', 'another_table'], 'another_db': true }```
@@ -156,20 +176,57 @@ Event name | Description
156
176
  `unknown` | Catch any other events
157
177
  `query` | [Insert/Update/Delete Query](https://dev.mysql.com/doc/internals/en/query-event.html)
158
178
  `intvar` | [Autoincrement and LAST_INSERT_ID](https://dev.mysql.com/doc/internals/en/intvar-event.html)
159
- `rotate` | [New Binlog file](https://dev.mysql.com/doc/internals/en/rotate-event.html) Not required to be included to rotate to new files, but it is required to be included in order to keep the `filename` and `position` properties updated with current values for [graceful restarting on errors](https://gist.github.com/numtel/5b37b2a7f47b380c1a099596c6f3db2f).
179
+ `rotate` | [New Binlog file](https://dev.mysql.com/doc/internals/en/rotate-event.html). The `filename` and `position` resume properties stay updated across rotations whether or not this event is included; include it only if you want to observe rotations yourself.
160
180
  `format` | [Format Description](https://dev.mysql.com/doc/internals/en/format-description-event.html)
161
181
  `xid` | [Transaction ID](https://dev.mysql.com/doc/internals/en/xid-event.html)
162
- `gtid` | GTID event with `gtid`, `sid`, `gno` properties
163
- `anonymousgtid` | Anonymous GTID event (same shape as `gtid`)
164
- `previousgtids` | Previous GTIDs event with `gtidSet` and `sids`
182
+ `gtid` | MySQL GTID event with `gtid`, `sid`, `gno` properties. Tagged GTIDs (MySQL 8.3+, `GTID_NEXT='AUTOMATIC:tag'`) arrive as the same event with `tag` set and `gtid` in `uuid:tag:gno` form
183
+ `anonymousgtid` | MySQL anonymous GTID event (same shape as `gtid`)
184
+ `previousgtids` | MySQL Previous GTIDs event with `gtidSet` and `sids`
185
+ `mariadbgtid` | MariaDB GTID event with `gtid` (`'domain-server-sequence'`), `domainId`, `serverId`, `seqNo` and flags (`standalone`, `isDdl`, XA states). Replaces the `BEGIN` query event of a transaction.
186
+ `mariadbgtidlist` | MariaDB GTID list event (start of every binlog file): the last GTID per domain and server seen so far, MariaDB's analogue of `previousgtids`
187
+ `binlogcheckpoint` | MariaDB binlog checkpoint event naming the oldest binlog file still needed for crash recovery (informational)
188
+ `annotaterows` | MariaDB: the SQL `statement` that produced the row events that follow (the analogue of MySQL's rows-query event). Only sent when `start()` was given `requestAnnotateRows: true`; useful for audit trails and statement attribution in CDC records
189
+ `rowsquery` | MySQL: the SQL `statement` that produced the row events that follow (the analogue of MariaDB's `annotaterows`). Sent to every consumer while the server runs with `binlog_rows_query_log_events=ON` (default `OFF`); no `start()` option is involved. The same sensitivity note applies: statement text may contain literals
190
+ `startencryption` | MariaDB encrypted-binlog marker (informational: the server decrypts before sending, the stream itself is cleartext)
191
+ `xaprepare` | XA PREPARE event (MySQL 5.7+ and MariaDB) terminating an XA-prepared event group, with `xidFormatId`, `xidGtrid`, `xidBqual` and `onePhase`
192
+ `heartbeat` | Sent by the server instead of real events: while the connection idles, and in place of transactions skipped server-side during a GTID resume. Carries `binlogName`; `nextPosition` holds the advanced position.
165
193
  `tablemap` | Before any row event (must be included for any other row events)
166
194
  `writerows` | Rows inserted, row data array available as `rows` property on event object
167
195
  `updaterows` | Rows changed, row data array available as `rows` property on event object
168
196
  `deleterows` | Rows deleted, row data array available as `rows` property on event object
169
- `transactionpayload` | Compressed transaction from MySQL 8.0.20+ servers with `binlog_transaction_compression=ON`. ZongJi cannot decode the row events inside it, so it emits an `error` (once per instance) naming the server setting responsible.
197
+ `transactionpayload` | Compressed transaction from MySQL 8.0.20+ servers with `binlog_transaction_compression=ON`. ZongJi cannot decode the row events inside it, so it emits an `error` (once per instance) naming the server setting responsible. (MariaDB's equivalent, `log_bin_compress=ON`, IS supported: its compressed events decode transparently as ordinary `query` and row events.)
170
198
  `partialupdaterows` | Partial JSON update from MySQL 8.0+ servers with `binlog_row_value_options=PARTIAL_JSON`. ZongJi cannot decode the JSON diff format, so it emits an `error` (once per instance) naming the server setting responsible.
171
199
 
172
- When the server runs with `gtid_mode=ON`, every emitted event carries a `gtid` property (`'uuid:sequence'`) identifying the transaction it belongs to, tracked internally even if `gtid` events are excluded by `includeEvents`. It is `undefined` for anonymous transactions and for events seen before the stream's first GTID event. Note that non-row events arriving between a transaction's commit and the next transaction's GTID event (e.g. a rotate) still carry the previous transaction's `gtid`. Use it for checkpointing and deduplication.
200
+ When the server runs with `gtid_mode=ON` (MySQL), or always on MariaDB, every emitted event carries a `gtid` property (`'uuid:sequence'` on MySQL, `'domain-server-sequence'` on MariaDB) identifying the transaction it belongs to, tracked internally even if GTID events are excluded by `includeEvents`. It is `undefined` for anonymous transactions, for events seen before the stream's first GTID event, and for events that belong to no transaction (e.g. a rotate arriving between one transaction's commit and the next transaction's GTID event; the commit event itself still carries its transaction's `gtid`). One exception: a DDL statement has no commit marker of its own, so its `gtid` persists on such trailing events until the next transaction begins. Use it for checkpointing and deduplication. The same value is available as `zongji.lastGtid` while an event is being delivered; it is only coherent when read synchronously inside a `'binlog'` handler, so checkpointing code should prefer `event.gtid`.
201
+
202
+ ### File and position resume
203
+
204
+ `zongji.options.filename` and `zongji.options.position` always hold a safe resume point for the connected server. The position advances only at transaction boundaries: it freezes when a transaction's first `TableMap` arrives and jumps to the commit marker's end position, so a persisted pair never points inside a transaction (where row events could be separated from the table metadata they need and silently dropped). Resuming from a pair persisted mid-transaction therefore replays that whole transaction: consumers should treat delivery as at-least-once and make their processing idempotent. Read the pair synchronously inside a `'binlog'` handler when checkpointing per event.
205
+
206
+ ### GTID-based resume
207
+
208
+ `zongji.gtidSet` exposes the executed GTIDs as a string: a MySQL GTID set (e.g. `'3e11fa47-…:1-27'`) or a MariaDB GTID position (e.g. `'0-1-1234'`), depending on the connected server. It is the value ZongJi started from, extended as each observed transaction *commits*, so a persisted value never claims a transaction whose row events were still in flight. Persist it alongside (or instead of) `filename`/`position` and resume with:
209
+
210
+ ```javascript
211
+ zongji.start({ gtidSet: savedGtidSet, serverId: 5 });
212
+ ```
213
+
214
+ Unlike file+position checkpoints, a GTID checkpoint is valid on any server in the same replication topology, which makes resuming across a failover possible. The server performs the skipping itself, so already-processed transactions are not resent.
215
+
216
+ The `GtidSet` class behind it is exported for consumers that need to work with GTID sets themselves, e.g. testing whether an incoming `event.gtid` was already covered by a snapshot:
217
+
218
+ ```javascript
219
+ import { GtidSet } from '@vlasky/zongji';
220
+
221
+ const snapshot = GtidSet.parse(savedGtidSet); // or @@gtid_executed
222
+ if (!snapshot.contains(event.gtid)) {
223
+ applyChange(event);
224
+ }
225
+ ```
226
+
227
+ `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
+
229
+ 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.
173
230
 
174
231
  **Event Methods**
175
232
 
@@ -183,6 +240,7 @@ Name | Description
183
240
  ## Important Notes
184
241
 
185
242
  * :star2: All MySQL column types are supported, with type casting similar to [mysql2](https://github.com/sidorares/node-mysql2).
243
+ * MariaDB-specific types and features are supported: `UUID`, `INET4` and `INET6` decode to their canonical text forms and `VECTOR` to a raw `Buffer` of little-endian float32s (matching mysql2 query results); MariaDB `JSON` is a `LONGTEXT` alias and yields strings; `COMPRESSED` columns are decompressed transparently; compressed binlog events (`log_bin_compress=ON`) decode as ordinary query/row events; and MariaDB 5.3-era "hires" temporal columns (tables written under `mysql56_temporal_format=OFF`) decode correctly.
186
244
  * :speak_no_evil: 64-bit integers are decoded exactly using native BigInt (see #108). If an integer is within the safe range of JS numbers (-2^53, 2^53), a Number is returned, otherwise an exact String. This also applies to 64-bit integers inside JSON columns.
187
245
  * :point_right: `TRUNCATE` statement does not cause corresponding `DeleteRows` event. Use unqualified `DELETE FROM` for same effect.
188
246
  * When using fractional seconds with `DATETIME` and `TIMESTAMP` data types, only millisecond precision is available due to the limit of Javascript's `Date` object.
@@ -191,8 +249,9 @@ Name | Description
191
249
  ## Run Tests
192
250
 
193
251
  * Install [Docker](https://www.docker.com/community-edition#download)
194
- * Run `docker-compose up -d` to start MySQL containers
195
- * Run `npm test` to execute the test suite
252
+ * Run `docker-compose up -d` to start the MySQL and MariaDB containers
253
+ * Run `npm test` to execute the test suite against MySQL 8.4 (port 3306)
254
+ * Run `TEST_MYSQL_PORT=3308 npm test` to execute it against MariaDB 11.8; MySQL-only tests skip automatically, and the MariaDB-specific suite in `test/mariadb.js` runs (it no-ops against MySQL)
196
255
 
197
256
  ## References
198
257
 
package/index.d.ts CHANGED
@@ -11,8 +11,27 @@ export interface ZongJiOptions {
11
11
  filename?: string;
12
12
  /** Binlog position to start from */
13
13
  position?: number;
14
+ /**
15
+ * Executed GTIDs to resume from (e.g. a persisted zongji.gtidSet): a
16
+ * MySQL GTID set ('uuid:1-5,...', tagged intervals as
17
+ * 'uuid:1-5:tag_a:1,...' on MySQL 8.3+) or a MariaDB GTID position
18
+ * ('0-1-1234,...'), matching the server flavour. The server locates
19
+ * the right binlog file and skips transactions already processed
20
+ * (MySQL: COM_BINLOG_DUMP_GTID, requires gtid_mode=ON; MariaDB:
21
+ * @slave_connect_state, always available), so this works across
22
+ * failover to another server in the same topology. '' streams the
23
+ * server's entire available history. Takes precedence over
24
+ * filename/position. A set containing tags uses a wire encoding only
25
+ * MySQL 8.3+ understands; older servers reject it.
26
+ */
27
+ gtidSet?: string;
14
28
  /** If true, use non-blocking mode */
15
29
  nonBlock?: boolean;
30
+ /**
31
+ * MariaDB only: request ANNOTATE_ROWS events (the SQL statement text
32
+ * preceding each row operation's TableMap events). Ignored on MySQL.
33
+ */
34
+ requestAnnotateRows?: boolean;
16
35
  /** List of event types to include (e.g., ['tablemap', 'writerows']) */
17
36
  includeEvents?: string[];
18
37
  /** List of event types to exclude */
@@ -23,13 +42,22 @@ export interface ZongJiOptions {
23
42
  excludeSchema?: Record<string, string[] | true>;
24
43
  }
25
44
 
26
- // Column metadata from INFORMATION_SCHEMA
45
+ // Column metadata, either fetched from INFORMATION_SCHEMA or synthesised
46
+ // from the binlog's own TableMap metadata (binlog_row_metadata=FULL).
47
+ // In the synthesised form COLUMN_TYPE is approximate for character columns
48
+ // (no display width) and COLLATION_NAME/COLUMN_COMMENT are not available.
27
49
  export interface ColumnSchema {
28
50
  COLUMN_NAME: string;
29
51
  COLLATION_NAME: string | null;
30
52
  CHARACTER_SET_NAME: string | null;
31
53
  COLUMN_COMMENT: string;
32
54
  COLUMN_TYPE: string;
55
+ /** Exact signedness from binlog metadata (MySQL 8.0+); numeric columns only */
56
+ UNSIGNED?: boolean;
57
+ /** ENUM value list from binlog metadata (binlog_row_metadata=FULL) */
58
+ ENUM_VALUES?: string[];
59
+ /** SET value list from binlog metadata (binlog_row_metadata=FULL) */
60
+ SET_VALUES?: string[];
33
61
  }
34
62
 
35
63
  // Column information from TableMap event
@@ -95,9 +123,15 @@ export interface GtidEvent extends BinlogEvent {
95
123
  flags: number;
96
124
  /** Source UUID */
97
125
  sid: string;
98
- /** Group number */
99
- gno: number;
100
- /** Full GTID string (sid:gno) */
126
+ /** Group number (exact string beyond Number.MAX_SAFE_INTEGER) */
127
+ gno: number | string;
128
+ /**
129
+ * GTID tag (MySQL 8.3+, set via GTID_NEXT='AUTOMATIC:tag'); '' for
130
+ * untagged transactions. Only present on events that arrived as
131
+ * GTID_TAGGED_LOG_EVENT; classic GTID events leave it undefined.
132
+ */
133
+ tag?: string;
134
+ /** Full GTID string (sid:gno, or sid:tag:gno when tagged) */
101
135
  gtid: string;
102
136
  }
103
137
 
@@ -109,8 +143,8 @@ export interface AnonymousGtidEvent extends BinlogEvent {
109
143
  flags: number;
110
144
  /** Source UUID */
111
145
  sid: string;
112
- /** Group number */
113
- gno: number;
146
+ /** Group number (exact string beyond Number.MAX_SAFE_INTEGER) */
147
+ gno: number | string;
114
148
  /** Full GTID string (sid:gno) */
115
149
  gtid: string;
116
150
  }
@@ -123,6 +157,12 @@ export interface GtidInterval {
123
157
 
124
158
  export interface GtidSidEntry {
125
159
  sid: string;
160
+ /**
161
+ * GTID tag ('' for the untagged entry). Only present when the server
162
+ * wrote the tagged set encoding (MySQL 8.3+ after any tagged GTID);
163
+ * each (sid, tag) pair is its own entry, repeating the sid.
164
+ */
165
+ tag?: string;
126
166
  intervals: GtidInterval[];
127
167
  }
128
168
 
@@ -197,7 +237,45 @@ export interface TableMapEvent extends BinlogEvent {
197
237
  columnsMetadata: Record<string, unknown>[];
198
238
  /** Reference to the tableMap cache */
199
239
  tableMap: Record<number, TableMapEntry>;
200
- /** Update column info after fetching from INFORMATION_SCHEMA */
240
+ /**
241
+ * Column names carried by the event itself (MySQL 8.0+ with
242
+ * binlog_row_metadata=FULL)
243
+ */
244
+ columnNames?: string[];
245
+ /**
246
+ * Per-column signedness from binlog metadata (MySQL 8.0+, MINIMAL and
247
+ * FULL); true = unsigned. Undefined entries are non-numeric columns.
248
+ */
249
+ signedness?: (boolean | undefined)[];
250
+ /**
251
+ * Primary key column indexes (0-based, all-columns numbering) from
252
+ * binlog_row_metadata=FULL
253
+ */
254
+ primaryKey?: number[];
255
+ /**
256
+ * Per-column visibility from binlog_row_metadata=FULL (MySQL 8.0.23+);
257
+ * false = INVISIBLE column
258
+ */
259
+ columnVisibility?: boolean[];
260
+ /**
261
+ * Per-column geometry subtype from binlog metadata (0 = geometry,
262
+ * 1 = point, ...); undefined entries are non-spatial columns
263
+ */
264
+ geometryTypes?: (number | undefined)[];
265
+ /**
266
+ * True when the event carries binlog_row_metadata=FULL metadata, in
267
+ * which case zongji decodes rows without querying INFORMATION_SCHEMA
268
+ */
269
+ hasSelfDescribingMetadata(): boolean;
270
+ /**
271
+ * True when the table contains classic temporal type codes, which on
272
+ * MariaDB may hide 5.3 "hires" columns that binlog metadata cannot
273
+ * reveal; such tables use the INFORMATION_SCHEMA path even under FULL
274
+ */
275
+ hasAmbiguousTemporalColumns(): boolean;
276
+ /** Build ColumnSchema entries from the event's own metadata (FULL only) */
277
+ buildColumnSchemas(): ColumnSchema[];
278
+ /** Update column info after table metadata is available */
201
279
  updateColumnInfo(): void;
202
280
  }
203
281
 
@@ -262,6 +340,141 @@ export interface PartialUpdateRowsEvent extends BinlogEvent {
262
340
  getEventName(): 'partialupdaterows';
263
341
  }
264
342
 
343
+ // Heartbeat event - sent while the connection idles (when a heartbeat
344
+ // period is configured) and after transactions were skipped server-side
345
+ // during a GTID dump; nextPosition carries the advanced position
346
+ export interface HeartbeatEvent extends BinlogEvent {
347
+ getTypeName(): 'Heartbeat';
348
+ getEventName(): 'heartbeat';
349
+ /** Current binlog filename */
350
+ binlogName: string;
351
+ /**
352
+ * Heartbeat position beyond 4 GiB (MariaDB only: sent in a sub-header
353
+ * when the u32 nextPosition cannot represent it, which is then 0)
354
+ */
355
+ position?: number | string;
356
+ }
357
+
358
+ // MariaDB GTID event (code 162) - replaces the BEGIN Query event of a
359
+ // transaction (standalone=false) or marks a standalone group such as DDL
360
+ export interface MariadbGtidEvent extends BinlogEvent {
361
+ getTypeName(): 'MariadbGtid';
362
+ getEventName(): 'mariadbgtid';
363
+ /** Sequence number within the domain (exact string beyond 2^53) */
364
+ seqNo: number | string;
365
+ /** Replication domain id */
366
+ domainId: number;
367
+ /** Originating server id (from the event header) */
368
+ serverId: number;
369
+ /** Raw flags2 byte */
370
+ flags2: number;
371
+ /** True for standalone groups (DDL, ...) with no terminating commit */
372
+ standalone: boolean;
373
+ /** True when the group can be safely rolled back */
374
+ transactional: boolean;
375
+ /** True when the group contains DDL */
376
+ isDdl: boolean;
377
+ /** XA PREPARE group; XID fields present */
378
+ preparedXa: boolean;
379
+ /** XA COMMIT/ROLLBACK group; XID fields present */
380
+ completedXa: boolean;
381
+ /** Group-commit id (same for all members of one group commit) */
382
+ commitId?: number | string;
383
+ /** XA format id, when preparedXa/completedXa */
384
+ xidFormatId?: number;
385
+ /** XA gtrid, when preparedXa/completedXa */
386
+ xidGtrid?: Buffer;
387
+ /** XA bqual, when preparedXa/completedXa */
388
+ xidBqual?: Buffer;
389
+ /** Raw flags_extra byte, when present */
390
+ flagsExtra?: number;
391
+ /** Number of extra engines, when FL_EXTRA_MULTI_ENGINE is set */
392
+ extraEngines?: number;
393
+ /** Start-alter sequence number, for COMMIT/ROLLBACK ALTER groups */
394
+ saSeqNo?: number | string;
395
+ /** Thread id of the originating connection (MariaDB 11.5+) */
396
+ threadId?: number;
397
+ /** Full GTID string (domain-server-sequence) */
398
+ gtid: string;
399
+ }
400
+
401
+ export interface MariadbGtidListEntry {
402
+ domainId: number;
403
+ serverId: number;
404
+ seqNo: number | string;
405
+ /** Full GTID string (domain-server-sequence) */
406
+ gtid: string;
407
+ }
408
+
409
+ // MariaDB GTID list event (code 163) - at the start of every binlog file,
410
+ // the last GTID per (domain, server) seen so far; MariaDB's analogue of
411
+ // MySQL's Previous_gtids
412
+ export interface MariadbGtidListEvent extends BinlogEvent {
413
+ getTypeName(): 'MariadbGtidList';
414
+ getEventName(): 'mariadbgtidlist';
415
+ count: number;
416
+ /** High 4 bits of the count word (FLAG_UNTIL_REACHED, FLAG_IGN_GTIDS) */
417
+ flags: number;
418
+ gtids: MariadbGtidListEntry[];
419
+ }
420
+
421
+ // MariaDB binlog checkpoint event (code 161) - XA-recovery marker naming
422
+ // the oldest binlog file that may still be needed for crash recovery
423
+ export interface BinlogCheckpointEvent extends BinlogEvent {
424
+ getTypeName(): 'BinlogCheckpoint';
425
+ getEventName(): 'binlogcheckpoint';
426
+ binlogName: string;
427
+ }
428
+
429
+ // MariaDB annotate rows event (code 160) - the SQL statement text for the
430
+ // row events that follow
431
+ export interface AnnotateRowsEvent extends BinlogEvent {
432
+ getTypeName(): 'AnnotateRows';
433
+ getEventName(): 'annotaterows';
434
+ /**
435
+ * The event carries the statement's original bytes with no charset
436
+ * metadata; decoded as utf8 (exact for utf8/utf8mb4 clients, the
437
+ * overwhelmingly common case).
438
+ */
439
+ statement: string;
440
+ }
441
+
442
+ // MySQL rows query event (code 29) - the SQL statement text for the row
443
+ // events that follow, sent while the server runs with
444
+ // binlog_rows_query_log_events=ON
445
+ export interface RowsQueryEvent extends BinlogEvent {
446
+ getTypeName(): 'RowsQuery';
447
+ getEventName(): 'rowsquery';
448
+ /**
449
+ * The event carries the statement's original bytes with no charset
450
+ * metadata; decoded as utf8 (exact for utf8/utf8mb4 clients, the
451
+ * overwhelmingly common case).
452
+ */
453
+ statement: string;
454
+ }
455
+
456
+ // MariaDB start encryption event (code 164) - informational; the dump
457
+ // thread decrypts server-side and the stream itself is cleartext
458
+ export interface StartEncryptionEvent extends BinlogEvent {
459
+ getTypeName(): 'StartEncryption';
460
+ getEventName(): 'startencryption';
461
+ scheme: number;
462
+ keyVersion: number;
463
+ nonce: Buffer;
464
+ }
465
+
466
+ // XA prepare event (code 38, MySQL 5.7+ and MariaDB) - terminates an
467
+ // XA-prepared event group
468
+ export interface XaPrepareEvent extends BinlogEvent {
469
+ getTypeName(): 'XaPrepare';
470
+ getEventName(): 'xaprepare';
471
+ /** XA COMMIT ... ONE PHASE (commits immediately) */
472
+ onePhase: boolean;
473
+ xidFormatId: number;
474
+ xidGtrid: Buffer;
475
+ xidBqual: Buffer;
476
+ }
477
+
265
478
  // Unknown event type
266
479
  export interface UnknownEvent extends BinlogEvent {
267
480
  getTypeName(): 'Unknown';
@@ -284,6 +497,14 @@ export type AnyBinlogEvent =
284
497
  | UpdateRowsEvent
285
498
  | TransactionPayloadEvent
286
499
  | PartialUpdateRowsEvent
500
+ | HeartbeatEvent
501
+ | MariadbGtidEvent
502
+ | MariadbGtidListEvent
503
+ | BinlogCheckpointEvent
504
+ | AnnotateRowsEvent
505
+ | RowsQueryEvent
506
+ | StartEncryptionEvent
507
+ | XaPrepareEvent
287
508
  | UnknownEvent;
288
509
 
289
510
  /** Union of all possible getTypeName() return values (e.g. 'Rotate', 'WriteRows') */
@@ -329,14 +550,42 @@ declare class ZongJi extends EventEmitter {
329
550
  stopped: boolean;
330
551
  /** Whether binlog checksum is enabled */
331
552
  useChecksum: boolean;
553
+ /**
554
+ * Server version string as reported by SELECT VERSION(), e.g.
555
+ * '8.4.5' or '11.8.8-MariaDB-ubu2404-log'. Set during start()
556
+ * initialisation, before the 'ready' event.
557
+ */
558
+ serverVersion: string | undefined;
559
+ /** True when the connected server is MariaDB (set during start()) */
560
+ isMariaDb: boolean;
332
561
  /** Current binlog options */
333
562
  options: {
334
563
  serverId?: number;
335
564
  filename?: string;
336
565
  position?: number;
337
566
  startAtEnd?: boolean;
567
+ gtidSet?: string;
338
568
  nonBlock?: boolean;
569
+ requestAnnotateRows?: boolean;
339
570
  };
571
+ /**
572
+ * The transactions this instance knows to be processed: the start()
573
+ * seed plus every transaction whose commit has been observed. A MySQL
574
+ * GTID set ('uuid:1-5,...') or a MariaDB GTID position ('0-1-1234,...')
575
+ * depending on the connected server. Persist it and pass to
576
+ * start({ gtidSet }) to resume, including on a different server.
577
+ * Undefined when no exact seed was available (a mid-file file+position
578
+ * start).
579
+ */
580
+ readonly gtidSet: string | undefined;
581
+ /**
582
+ * The GTID of the transaction whose events are currently being
583
+ * delivered (equal to their event.gtid); undefined between
584
+ * transactions and for anonymous transactions. Only coherent when
585
+ * read synchronously inside a 'binlog' handler: checkpointing code
586
+ * should use event.gtid.
587
+ */
588
+ readonly lastGtid: string | undefined;
340
589
  /** Current filter settings */
341
590
  filters: {
342
591
  includeEvents?: string[];
@@ -394,3 +643,31 @@ declare class ZongJi extends EventEmitter {
394
643
  }
395
644
 
396
645
  export default ZongJi;
646
+
647
+ /**
648
+ * A MySQL GTID set (the model behind zongji.gtidSet): which transactions
649
+ * per source are already executed. String form matches @@gtid_executed,
650
+ * including tagged GTIDs (MySQL 8.3+): 'uuid:1-5:11,uuid2:1-27',
651
+ * 'uuid:1-5:tag_a:1'. Exported so consumers can parse persisted sets and
652
+ * test event.gtid membership without their own parser.
653
+ */
654
+ export class GtidSet {
655
+ /**
656
+ * Parses a GTID set string ('' gives an empty set). Within a uuid
657
+ * block a non-numeric item is a tag naming the source of the interval
658
+ * items that follow it. Throws on malformed input.
659
+ */
660
+ static parse(text: string): GtidSet;
661
+ /** Adds a single transaction ('uuid:gno' or 'uuid:tag:gno') */
662
+ add(gtid: string): void;
663
+ /**
664
+ * Whether a single transaction ('uuid:gno' or 'uuid:tag:gno', e.g. an
665
+ * event.gtid) is contained in the set. null/undefined (an anonymous
666
+ * transaction's event.gtid) is never contained; a malformed string
667
+ * throws.
668
+ */
669
+ contains(gtid: string | null | undefined): boolean;
670
+ isEmpty(): boolean;
671
+ /** Canonical text form (sorted; tagged intervals after untagged) */
672
+ toString(): string;
673
+ }