@vlasky/zongji 0.7.0 → 0.7.1

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,14 @@
2
2
 
3
3
  All notable changes to @vlasky/zongji since forking from nevill/zongji.
4
4
 
5
+ ## [0.7.1] - 2026-07-04
6
+
7
+ - 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.
8
+ - Fix a resume-position gap that could silently drop row events. `options.position` was advanced past TableMap events on the cached-metadata path, so a consumer persisting `filename`/`position` for reconnect could resume between a TableMap and its row events; the resumed instance had no metadata for the table id and dropped those rows with no error. TableMap events no longer advance the resume position, closing the gap for single-table statements (the common case). A narrower window remains for multi-table statements (multi-table UPDATE, foreign-key cascades), where the server writes all TableMap events before any row events: emitting the first table's rows still advances the position past the later TableMaps. Rows already processed before a crash may be re-delivered after resume (at-least-once), which is recoverable where dropping is not.
9
+ - Fix rotate events corrupting the `filename`/`position` resume pair. A rotate's header position refers to the old binlog file (0 for the artificial rotate at the start of every dump), yet it was written into `options.position` alongside the new file's name; a consumer resuming from that pair after a real rotation could get "position > file size" or a mid-event read, and the artificial rotate silently reset the start position to 0. The rotate's payload position (the start of the new file) is now used, and the filename update is unconditional. Present in every zongji release since the original upstream project.
10
+ - Fix a corrupt GTID event's parse error being swallowed when `gtid` is excluded by `includeEvents`; the whole following transaction was then silently mislabelled as anonymous. The error now reaches the `error` event regardless of filtering.
11
+ - Schema drift between a binlog event being written and the metadata fetch (e.g. a column dropped in between) now emits a descriptive error naming the table and column counts, instead of throwing a bare TypeError from inside event parsing; the affected table's rows are skipped until its next TableMap event refreshes the metadata.
12
+
5
13
  ## [0.7.0] - 2026-07-04
6
14
 
7
15
  ### Breaking changes
package/README.md CHANGED
@@ -169,7 +169,7 @@ Event name | Description
169
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.
170
170
  `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
171
 
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. Use it for checkpointing and deduplication.
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.
173
173
 
174
174
  **Event Methods**
175
175
 
package/index.js CHANGED
@@ -343,12 +343,17 @@ class ZongJi extends EventEmitter {
343
343
  return;
344
344
  }
345
345
 
346
- // A duplicate start() while one is already initialising is ignored;
347
- // the first call completes. But if stop() intervened, this start()
348
- // must proceed as a restart: the epoch below makes the stale
349
- // initialisation chain abort, so exactly one binlog dump command is
350
- // ever enqueued.
346
+ // A duplicate start() while one is already initialising is ignored -
347
+ // the first call completes - but its filters are applied, exactly as
348
+ // in the already-running branch above: filters are snapshotted, and
349
+ // re-calling start() is the documented way to update them, so updates
350
+ // made during the initialisation window must not be lost. Stream
351
+ // options (filename/position/serverId) still come from the first
352
+ // call. If stop() intervened, this start() instead proceeds as a
353
+ // restart: the epoch below makes the stale initialisation chain
354
+ // abort, so exactly one binlog dump command is ever enqueued.
351
355
  if (this._starting && !this.stopped) {
356
+ this._filters(options);
352
357
  return;
353
358
  }
354
359
  this._starting = true;
@@ -429,8 +434,18 @@ class ZongJi extends EventEmitter {
429
434
  this.connection.pause();
430
435
  attachGtid(event);
431
436
  this._fetchTableInfo(event, () => {
432
- // merge the column info with metadata
433
- event.updateColumnInfo();
437
+ try {
438
+ // merge the column info with metadata
439
+ event.updateColumnInfo();
440
+ } catch (err) {
441
+ // Schema drift between binlog write and metadata fetch:
442
+ // drop the cached entry (subsequent row events for this
443
+ // table id are skipped rather than misdecoded) and report
444
+ delete this.tableMap[event.tableId];
445
+ this.emit('error', err);
446
+ if (this.connection) this.connection.resume();
447
+ return;
448
+ }
434
449
  this.emit('binlog', event);
435
450
  if (this.connection) this.connection.resume();
436
451
  });
@@ -439,12 +454,31 @@ class ZongJi extends EventEmitter {
439
454
  break;
440
455
  }
441
456
  case 'Rotate':
442
- if (this.options.filename !== event.binlogName) {
443
- this.options.filename = event.binlogName;
444
- }
457
+ // The payload position is the first event of the NEW file: the
458
+ // only value coherent with binlogName. The header nextPosition
459
+ // refers to the OLD file (0 for the artificial rotate at dump
460
+ // start), so rotates are excluded from the generic update below;
461
+ // persisting (new filename, old-file offset) would corrupt the
462
+ // resume point until the next non-rotate event repaired it.
463
+ this.options.filename = event.binlogName;
464
+ this.options.position = event.position;
445
465
  break;
446
466
  }
447
- this.options.position = event.nextPosition;
467
+ // Never advance the resume position past a TableMap: a consumer
468
+ // persisting options.position could otherwise resume in the gap
469
+ // between a TableMap and its row events, and with no cached table
470
+ // metadata those rows would be silently dropped. Holding position
471
+ // back means the TableMap is replayed before its rows on resume;
472
+ // rows already seen may be re-emitted (at-least-once), which is
473
+ // recoverable where dropping is not. A narrower window remains for
474
+ // multi-table statements (all TableMaps precede all row events, so
475
+ // emitting the first table's rows advances past the later
476
+ // TableMaps); the complete fix is advancing only at transaction
477
+ // boundaries, tracked pre-filter.
478
+ const typeName = event.getTypeName();
479
+ if (typeName !== 'TableMap' && typeName !== 'Rotate') {
480
+ this.options.position = event.nextPosition;
481
+ }
448
482
  this.emit('binlog', attachGtid(event));
449
483
  };
450
484
 
@@ -261,6 +261,16 @@ class TableMap extends BinlogEvent {
261
261
  const tableMap = this.tableMap[this.tableId];
262
262
 
263
263
  const columnSchemas = tableMap.columnSchemas;
264
+ // Schema drift: the table was altered between this binlog event being
265
+ // written and the metadata fetch. Fail diagnosably rather than with a
266
+ // bare TypeError from indexing missing columns.
267
+ if (!columnSchemas || columnSchemas.length < this.columnCount) {
268
+ throw new Error(
269
+ `Table ${this.schemaName}.${this.tableName} schema changed between ` +
270
+ `binlog event and metadata fetch: the event has ${this.columnCount} ` +
271
+ 'columns, fetched metadata has ' +
272
+ `${columnSchemas ? columnSchemas.length : 0}`);
273
+ }
264
274
  const columns = [];
265
275
  for (let j = 0; j < this.columnCount; j++) {
266
276
  columns.push({
@@ -159,7 +159,10 @@ export default function initBinlogClass(zongji) {
159
159
  }
160
160
  }
161
161
 
162
- if (zongji._skipEvent(eventName)) {
162
+ // A GTID parse failure corrupts transaction attribution for the
163
+ // whole following transaction, so it must surface even when the
164
+ // event type itself is filtered out
165
+ if (zongji._skipEvent(eventName) && !error) {
163
166
  return Binlog.prototype.binlogData;
164
167
  }
165
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vlasky/zongji",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "MySQL binlog-based change data capture (CDC) for Node.js",
5
5
  "type": "module",
6
6
  "main": "index.js",