@powersync/service-module-mysql 0.6.4 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/api/MySQLRouteAPIAdapter.d.ts +1 -1
  3. package/dist/api/MySQLRouteAPIAdapter.js +1 -1
  4. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
  5. package/dist/replication/BinLogReplicationJob.d.ts +2 -0
  6. package/dist/replication/BinLogReplicationJob.js +10 -3
  7. package/dist/replication/BinLogReplicationJob.js.map +1 -1
  8. package/dist/replication/BinLogReplicator.d.ts +1 -0
  9. package/dist/replication/BinLogReplicator.js +22 -0
  10. package/dist/replication/BinLogReplicator.js.map +1 -1
  11. package/dist/replication/BinLogStream.d.ts +17 -1
  12. package/dist/replication/BinLogStream.js +126 -174
  13. package/dist/replication/BinLogStream.js.map +1 -1
  14. package/dist/replication/MySQLConnectionManager.d.ts +1 -1
  15. package/dist/replication/MySQLConnectionManager.js +2 -1
  16. package/dist/replication/MySQLConnectionManager.js.map +1 -1
  17. package/dist/replication/zongji/BinLogListener.d.ts +54 -0
  18. package/dist/replication/zongji/BinLogListener.js +192 -0
  19. package/dist/replication/zongji/BinLogListener.js.map +1 -0
  20. package/dist/replication/zongji/zongji-utils.d.ts +5 -4
  21. package/dist/replication/zongji/zongji-utils.js +3 -0
  22. package/dist/replication/zongji/zongji-utils.js.map +1 -1
  23. package/dist/types/types.d.ts +2 -0
  24. package/dist/types/types.js +5 -1
  25. package/dist/types/types.js.map +1 -1
  26. package/dist/utils/mysql-utils.js +1 -0
  27. package/dist/utils/mysql-utils.js.map +1 -1
  28. package/package.json +10 -10
  29. package/src/api/MySQLRouteAPIAdapter.ts +1 -1
  30. package/src/replication/BinLogReplicationJob.ts +11 -3
  31. package/src/replication/BinLogReplicator.ts +25 -0
  32. package/src/replication/BinLogStream.ts +151 -201
  33. package/src/replication/MySQLConnectionManager.ts +2 -1
  34. package/src/replication/zongji/BinLogListener.ts +243 -0
  35. package/src/replication/zongji/zongji-utils.ts +10 -5
  36. package/src/types/types.ts +8 -1
  37. package/src/utils/mysql-utils.ts +1 -0
  38. package/test/src/BinLogListener.test.ts +161 -0
  39. package/test/src/BinLogStream.test.ts +4 -9
  40. package/test/src/mysql-to-sqlite.test.ts +1 -1
  41. package/test/src/util.ts +12 -0
  42. package/test/tsconfig.json +1 -1
  43. package/tsconfig.tsbuildinfo +1 -1
  44. package/src/replication/zongji/zongji.d.ts +0 -129
@@ -1,6 +1,10 @@
1
- import { logger, ReplicationAbortedError, ReplicationAssertionError } from '@powersync/lib-services-framework';
1
+ import {
2
+ Logger,
3
+ logger as defaultLogger,
4
+ ReplicationAbortedError,
5
+ ReplicationAssertionError
6
+ } from '@powersync/lib-services-framework';
2
7
  import * as sync_rules from '@powersync/service-sync-rules';
3
- import async from 'async';
4
8
 
5
9
  import {
6
10
  ColumnDescriptor,
@@ -9,22 +13,22 @@ import {
9
13
  MetricsEngine,
10
14
  storage
11
15
  } from '@powersync/service-core';
12
- import mysql, { FieldPacket } from 'mysql2';
13
-
14
- import { BinLogEvent, StartOptions, TableMapEntry } from '@powersync/mysql-zongji';
16
+ import mysql from 'mysql2';
15
17
  import mysqlPromise from 'mysql2/promise';
18
+
19
+ import { TableMapEntry } from '@powersync/mysql-zongji';
16
20
  import * as common from '../common/common-index.js';
17
- import { isBinlogStillAvailable, ReplicatedGTID, toColumnDescriptors } from '../common/common-index.js';
18
21
  import { createRandomServerId, escapeMysqlTableName } from '../utils/mysql-utils.js';
19
22
  import { MySQLConnectionManager } from './MySQLConnectionManager.js';
20
- import * as zongji_utils from './zongji/zongji-utils.js';
21
23
  import { ReplicationMetric } from '@powersync/service-types';
24
+ import { BinLogEventHandler, BinLogListener, Row } from './zongji/BinLogListener.js';
22
25
 
23
26
  export interface BinLogStreamOptions {
24
27
  connections: MySQLConnectionManager;
25
28
  storage: storage.SyncRulesBucketStorage;
26
29
  metrics: MetricsEngine;
27
30
  abortSignal: AbortSignal;
31
+ logger?: Logger;
28
32
  }
29
33
 
30
34
  interface MysqlRelId {
@@ -34,16 +38,14 @@ interface MysqlRelId {
34
38
 
35
39
  interface WriteChangePayload {
36
40
  type: storage.SaveOperationTag;
37
- data: Data;
38
- previous_data?: Data;
41
+ row: Row;
42
+ previous_row?: Row;
39
43
  database: string;
40
44
  table: string;
41
45
  sourceTable: storage.SourceTable;
42
46
  columns: Map<string, ColumnDescriptor>;
43
47
  }
44
48
 
45
- export type Data = Record<string, any>;
46
-
47
49
  export class BinlogConfigurationError extends Error {
48
50
  constructor(message: string) {
49
51
  super(message);
@@ -70,7 +72,21 @@ export class BinLogStream {
70
72
 
71
73
  private tableCache = new Map<string | number, storage.SourceTable>();
72
74
 
75
+ private logger: Logger;
76
+
77
+ /**
78
+ * Time of the oldest uncommitted change, according to the source db.
79
+ * This is used to determine the replication lag.
80
+ */
81
+ private oldestUncommittedChange: Date | null = null;
82
+ /**
83
+ * Keep track of whether we have done a commit or keepalive yet.
84
+ * We can only compute replication lag if isStartingReplication == false, or oldestUncommittedChange is present.
85
+ */
86
+ private isStartingReplication = true;
87
+
73
88
  constructor(private options: BinLogStreamOptions) {
89
+ this.logger = options.logger ?? defaultLogger;
74
90
  this.storage = options.storage;
75
91
  this.connections = options.connections;
76
92
  this.syncRules = options.storage.getParsedSyncRules({ defaultSchema: this.defaultSchema });
@@ -148,7 +164,7 @@ export class BinLogStream {
148
164
  await this.snapshotTable(connection.connection, batch, result.table);
149
165
  await promiseConnection.query('COMMIT');
150
166
  } catch (e) {
151
- await tryRollback(promiseConnection);
167
+ await this.tryRollback(promiseConnection);
152
168
  throw e;
153
169
  }
154
170
  } finally {
@@ -206,7 +222,7 @@ AND table_type = 'BASE TABLE';`,
206
222
  [tablePattern.schema, tablePattern.name]
207
223
  );
208
224
  if (result[0].length == 0) {
209
- logger.info(`Skipping ${tablePattern.schema}.${name} - no table exists/is not a base table`);
225
+ this.logger.info(`Skipping ${tablePattern.schema}.${name} - no table exists/is not a base table`);
210
226
  continue;
211
227
  }
212
228
 
@@ -241,16 +257,16 @@ AND table_type = 'BASE TABLE';`,
241
257
  const status = await this.storage.getStatus();
242
258
  const lastKnowGTID = status.checkpoint_lsn ? common.ReplicatedGTID.fromSerialized(status.checkpoint_lsn) : null;
243
259
  if (status.snapshot_done && status.checkpoint_lsn) {
244
- logger.info(`Initial replication already done.`);
260
+ this.logger.info(`Initial replication already done.`);
245
261
 
246
262
  if (lastKnowGTID) {
247
263
  // Check if the binlog is still available. If it isn't we need to snapshot again.
248
264
  const connection = await this.connections.getConnection();
249
265
  try {
250
- const isAvailable = await isBinlogStillAvailable(connection, lastKnowGTID.position.filename);
266
+ const isAvailable = await common.isBinlogStillAvailable(connection, lastKnowGTID.position.filename);
251
267
  if (!isAvailable) {
252
- logger.info(
253
- `Binlog file ${lastKnowGTID.position.filename} is no longer available, starting initial replication again.`
268
+ this.logger.info(
269
+ `BinLog file ${lastKnowGTID.position.filename} is no longer available, starting initial replication again.`
254
270
  );
255
271
  }
256
272
  return isAvailable;
@@ -272,14 +288,14 @@ AND table_type = 'BASE TABLE';`,
272
288
  * and starts again from scratch.
273
289
  */
274
290
  async startInitialReplication() {
275
- await this.storage.clear();
291
+ await this.storage.clear({ signal: this.abortSignal });
276
292
  // Replication will be performed in a single transaction on this connection
277
293
  const connection = await this.connections.getStreamingConnection();
278
294
  const promiseConnection = (connection as mysql.Connection).promise();
279
295
  const headGTID = await common.readExecutedGtid(promiseConnection);
280
- logger.info(`Using snapshot checkpoint GTID: '${headGTID}'`);
296
+ this.logger.info(`Using snapshot checkpoint GTID: '${headGTID}'`);
281
297
  try {
282
- logger.info(`Starting initial replication`);
298
+ this.logger.info(`Starting initial replication`);
283
299
  await promiseConnection.query<mysqlPromise.RowDataPacket[]>(
284
300
  'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY'
285
301
  );
@@ -288,7 +304,12 @@ AND table_type = 'BASE TABLE';`,
288
304
 
289
305
  const sourceTables = this.syncRules.getSourceTables();
290
306
  await this.storage.startBatch(
291
- { zeroLSN: ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
307
+ {
308
+ logger: this.logger,
309
+ zeroLSN: common.ReplicatedGTID.ZERO.comparable,
310
+ defaultSchema: this.defaultSchema,
311
+ storeCurrentData: true
312
+ },
292
313
  async (batch) => {
293
314
  for (let tablePattern of sourceTables) {
294
315
  const tables = await this.getQualifiedTableNames(batch, tablePattern);
@@ -301,10 +322,10 @@ AND table_type = 'BASE TABLE';`,
301
322
  await batch.commit(headGTID.comparable);
302
323
  }
303
324
  );
304
- logger.info(`Initial replication done`);
325
+ this.logger.info(`Initial replication done`);
305
326
  await promiseConnection.query('COMMIT');
306
327
  } catch (e) {
307
- await tryRollback(promiseConnection);
328
+ await this.tryRollback(promiseConnection);
308
329
  throw e;
309
330
  } finally {
310
331
  connection.release();
@@ -316,7 +337,7 @@ AND table_type = 'BASE TABLE';`,
316
337
  batch: storage.BucketStorageBatch,
317
338
  table: storage.SourceTable
318
339
  ) {
319
- logger.info(`Replicating ${table.qualifiedName}`);
340
+ this.logger.info(`Replicating ${table.qualifiedName}`);
320
341
  // TODO count rows and log progress at certain batch sizes
321
342
 
322
343
  // MAX_EXECUTION_TIME(0) hint disables execution timeout for this query
@@ -324,9 +345,9 @@ AND table_type = 'BASE TABLE';`,
324
345
  const stream = query.stream();
325
346
 
326
347
  let columns: Map<string, ColumnDescriptor> | undefined = undefined;
327
- stream.on('fields', (fields: FieldPacket[]) => {
348
+ stream.on('fields', (fields: mysql.FieldPacket[]) => {
328
349
  // Map the columns and their types
329
- columns = toColumnDescriptors(fields);
350
+ columns = common.toColumnDescriptors(fields);
330
351
  });
331
352
 
332
353
  for await (let row of stream) {
@@ -359,7 +380,7 @@ AND table_type = 'BASE TABLE';`,
359
380
  // all connections automatically closed, including this one.
360
381
  await this.initReplication();
361
382
  await this.streamChanges();
362
- logger.info('BinlogStream has been shut down');
383
+ this.logger.info('BinLogStream has been shut down');
363
384
  } catch (e) {
364
385
  await this.storage.reportError(e);
365
386
  throw e;
@@ -372,7 +393,7 @@ AND table_type = 'BASE TABLE';`,
372
393
  connection.release();
373
394
 
374
395
  if (errors.length > 0) {
375
- throw new BinlogConfigurationError(`Binlog Configuration Errors: ${errors.join(', ')}`);
396
+ throw new BinlogConfigurationError(`BinLog Configuration Errors: ${errors.join(', ')}`);
376
397
  }
377
398
 
378
399
  const initialReplicationCompleted = await this.checkInitialReplicated();
@@ -383,7 +404,12 @@ AND table_type = 'BASE TABLE';`,
383
404
  // This is needed for includeSchema to work correctly.
384
405
  const sourceTables = this.syncRules.getSourceTables();
385
406
  await this.storage.startBatch(
386
- { zeroLSN: ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
407
+ {
408
+ logger: this.logger,
409
+ zeroLSN: common.ReplicatedGTID.ZERO.comparable,
410
+ defaultSchema: this.defaultSchema,
411
+ storeCurrentData: true
412
+ },
387
413
  async (batch) => {
388
414
  for (let tablePattern of sourceTables) {
389
415
  await this.getQualifiedTableNames(batch, tablePattern);
@@ -407,14 +433,12 @@ AND table_type = 'BASE TABLE';`,
407
433
  // Auto-activate as soon as initial replication is done
408
434
  await this.storage.autoActivate();
409
435
  const serverId = createRandomServerId(this.storage.group_id);
410
- logger.info(`Starting replication. Created replica client with serverId:${serverId}`);
411
436
 
412
437
  const connection = await this.connections.getConnection();
413
438
  const { checkpoint_lsn } = await this.storage.getStatus();
414
439
  if (checkpoint_lsn) {
415
- logger.info(`Existing checkpoint found: ${checkpoint_lsn}`);
440
+ this.logger.info(`Existing checkpoint found: ${checkpoint_lsn}`);
416
441
  }
417
-
418
442
  const fromGTID = checkpoint_lsn
419
443
  ? common.ReplicatedGTID.fromSerialized(checkpoint_lsn)
420
444
  : await common.readExecutedGtid(connection);
@@ -423,179 +447,92 @@ AND table_type = 'BASE TABLE';`,
423
447
 
424
448
  if (!this.stopped) {
425
449
  await this.storage.startBatch(
426
- { zeroLSN: ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
450
+ { zeroLSN: common.ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
427
451
  async (batch) => {
428
- const zongji = this.connections.createBinlogListener();
429
-
430
- let currentGTID: common.ReplicatedGTID | null = null;
431
-
432
- const queue = async.queue(async (evt: BinLogEvent) => {
433
- // State machine
434
- switch (true) {
435
- case zongji_utils.eventIsGTIDLog(evt):
436
- currentGTID = common.ReplicatedGTID.fromBinLogEvent({
437
- raw_gtid: {
438
- server_id: evt.serverId,
439
- transaction_range: evt.transactionRange
440
- },
441
- position: {
442
- filename: binLogPositionState.filename,
443
- offset: evt.nextPosition
444
- }
445
- });
446
- break;
447
- case zongji_utils.eventIsRotation(evt):
448
- // Update the position
449
- binLogPositionState.filename = evt.binlogName;
450
- binLogPositionState.offset = evt.position;
451
- break;
452
- case zongji_utils.eventIsWriteMutation(evt):
453
- const writeTableInfo = evt.tableMap[evt.tableId];
454
- await this.writeChanges(batch, {
455
- type: storage.SaveOperationTag.INSERT,
456
- data: evt.rows,
457
- tableEntry: writeTableInfo
458
- });
459
- break;
460
- case zongji_utils.eventIsUpdateMutation(evt):
461
- const updateTableInfo = evt.tableMap[evt.tableId];
462
- await this.writeChanges(batch, {
463
- type: storage.SaveOperationTag.UPDATE,
464
- data: evt.rows.map((row) => row.after),
465
- previous_data: evt.rows.map((row) => row.before),
466
- tableEntry: updateTableInfo
467
- });
468
- break;
469
- case zongji_utils.eventIsDeleteMutation(evt):
470
- const deleteTableInfo = evt.tableMap[evt.tableId];
471
- await this.writeChanges(batch, {
472
- type: storage.SaveOperationTag.DELETE,
473
- data: evt.rows,
474
- tableEntry: deleteTableInfo
475
- });
476
- break;
477
- case zongji_utils.eventIsXid(evt):
478
- this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED).add(1);
479
- // Need to commit with a replicated GTID with updated next position
480
- await batch.commit(
481
- new common.ReplicatedGTID({
482
- raw_gtid: currentGTID!.raw,
483
- position: {
484
- filename: binLogPositionState.filename,
485
- offset: evt.nextPosition
486
- }
487
- }).comparable
488
- );
489
- currentGTID = null;
490
- // chunks_replicated_total.add(1);
491
- break;
492
- }
493
- }, 1);
494
-
495
- zongji.on('binlog', (evt: BinLogEvent) => {
496
- if (!this.stopped) {
497
- logger.info(`Received Binlog event:${evt.getEventName()}`);
498
- queue.push(evt);
499
- } else {
500
- logger.info(`Replication is busy stopping, ignoring event ${evt.getEventName()}`);
501
- }
502
- });
503
-
504
- // Set a heartbeat interval for the Zongji replication connection
505
- // Zongji does not explicitly handle the heartbeat events - they are categorized as event:unknown
506
- // The heartbeat events are enough to keep the connection alive for setTimeout to work on the socket.
507
- await new Promise((resolve, reject) => {
508
- zongji.connection.query(
509
- // In nanoseconds, 10^9 = 1s
510
- 'set @master_heartbeat_period=28*1000000000',
511
- function (error: any, results: any, fields: any) {
512
- if (error) {
513
- reject(error);
514
- } else {
515
- resolve(results);
516
- }
517
- }
518
- );
519
- });
520
- logger.info('Successfully set up replication connection heartbeat...');
521
-
522
- // The _socket member is only set after a query is run on the connection, so we set the timeout after setting the heartbeat.
523
- // The timeout here must be greater than the master_heartbeat_period.
524
- const socket = zongji.connection._socket!;
525
- socket.setTimeout(60_000, () => {
526
- socket.destroy(new Error('Replication connection timeout.'));
527
- });
528
-
529
- if (this.stopped) {
530
- // Powersync is shutting down, don't start replicating
531
- return;
532
- }
533
-
534
- logger.info(`Reading binlog from: ${binLogPositionState.filename}:${binLogPositionState.offset}`);
452
+ const binlogEventHandler = this.createBinlogEventHandler(batch);
535
453
  // Only listen for changes to tables in the sync rules
536
454
  const includedTables = [...this.tableCache.values()].map((table) => table.table);
537
- zongji.start({
538
- // We ignore the unknown/heartbeat event since it currently serves no purpose other than to keep the connection alive
539
- includeEvents: ['tablemap', 'writerows', 'updaterows', 'deleterows', 'xid', 'rotate', 'gtidlog'],
540
- excludeEvents: [],
541
- includeSchema: { [this.defaultSchema]: includedTables },
542
- filename: binLogPositionState.filename,
543
- position: binLogPositionState.offset,
544
- serverId: serverId
545
- } satisfies StartOptions);
546
-
547
- // Forever young
548
- await new Promise<void>((resolve, reject) => {
549
- zongji.on('error', (error) => {
550
- logger.error('Binlog listener error:', error);
551
- zongji.stop();
552
- queue.kill();
553
- reject(error);
554
- });
555
-
556
- zongji.on('stopped', () => {
557
- logger.info('Binlog listener stopped. Replication ended.');
558
- resolve();
559
- });
560
-
561
- queue.error((error) => {
562
- logger.error('Binlog listener queue error:', error);
563
- zongji.stop();
564
- queue.kill();
565
- reject(error);
566
- });
567
-
568
- const stop = () => {
569
- logger.info('Abort signal received, stopping replication...');
570
- zongji.stop();
571
- queue.kill();
572
- resolve();
573
- };
574
-
575
- this.abortSignal.addEventListener('abort', stop, { once: true });
576
-
577
- if (this.stopped) {
578
- // Generally this should have been picked up early, but we add this here as a failsafe.
579
- stop();
580
- }
455
+ const binlogListener = new BinLogListener({
456
+ logger: this.logger,
457
+ includedTables: includedTables,
458
+ startPosition: binLogPositionState,
459
+ connectionManager: this.connections,
460
+ serverId: serverId,
461
+ eventHandler: binlogEventHandler
581
462
  });
463
+
464
+ this.abortSignal.addEventListener(
465
+ 'abort',
466
+ () => {
467
+ this.logger.info('Abort signal received, stopping replication...');
468
+ binlogListener.stop();
469
+ },
470
+ { once: true }
471
+ );
472
+
473
+ // Only returns when the replication is stopped or interrupted by an error
474
+ await binlogListener.start();
582
475
  }
583
476
  );
584
477
  }
585
478
  }
586
479
 
480
+ private createBinlogEventHandler(batch: storage.BucketStorageBatch): BinLogEventHandler {
481
+ return {
482
+ onWrite: async (rows: Row[], tableMap: TableMapEntry) => {
483
+ await this.writeChanges(batch, {
484
+ type: storage.SaveOperationTag.INSERT,
485
+ rows: rows,
486
+ tableEntry: tableMap
487
+ });
488
+ },
489
+
490
+ onUpdate: async (rowsAfter: Row[], rowsBefore: Row[], tableMap: TableMapEntry) => {
491
+ await this.writeChanges(batch, {
492
+ type: storage.SaveOperationTag.UPDATE,
493
+ rows: rowsAfter,
494
+ rows_before: rowsBefore,
495
+ tableEntry: tableMap
496
+ });
497
+ },
498
+ onDelete: async (rows: Row[], tableMap: TableMapEntry) => {
499
+ await this.writeChanges(batch, {
500
+ type: storage.SaveOperationTag.DELETE,
501
+ rows: rows,
502
+ tableEntry: tableMap
503
+ });
504
+ },
505
+ onCommit: async (lsn: string) => {
506
+ this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED).add(1);
507
+ const didCommit = await batch.commit(lsn, { oldestUncommittedChange: this.oldestUncommittedChange });
508
+ if (didCommit) {
509
+ this.oldestUncommittedChange = null;
510
+ this.isStartingReplication = false;
511
+ }
512
+ },
513
+ onTransactionStart: async (options) => {
514
+ if (this.oldestUncommittedChange == null) {
515
+ this.oldestUncommittedChange = options.timestamp;
516
+ }
517
+ },
518
+ onRotate: async () => {
519
+ this.isStartingReplication = false;
520
+ }
521
+ };
522
+ }
523
+
587
524
  private async writeChanges(
588
525
  batch: storage.BucketStorageBatch,
589
526
  msg: {
590
527
  type: storage.SaveOperationTag;
591
- data: Data[];
592
- previous_data?: Data[];
528
+ rows: Row[];
529
+ rows_before?: Row[];
593
530
  tableEntry: TableMapEntry;
594
531
  }
595
532
  ): Promise<storage.FlushedResult | null> {
596
- const columns = toColumnDescriptors(msg.tableEntry);
533
+ const columns = common.toColumnDescriptors(msg.tableEntry);
597
534
 
598
- for (const [index, row] of msg.data.entries()) {
535
+ for (const [index, row] of msg.rows.entries()) {
599
536
  await this.writeChange(batch, {
600
537
  type: msg.type,
601
538
  database: msg.tableEntry.parentSchema,
@@ -607,8 +544,8 @@ AND table_type = 'BASE TABLE';`,
607
544
  ),
608
545
  table: msg.tableEntry.tableName,
609
546
  columns: columns,
610
- data: row,
611
- previous_data: msg.previous_data?.[index]
547
+ row: row,
548
+ previous_row: msg.rows_before?.[index]
612
549
  });
613
550
  }
614
551
  return null;
@@ -621,7 +558,7 @@ AND table_type = 'BASE TABLE';`,
621
558
  switch (payload.type) {
622
559
  case storage.SaveOperationTag.INSERT:
623
560
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
624
- const record = common.toSQLiteRow(payload.data, payload.columns);
561
+ const record = common.toSQLiteRow(payload.row, payload.columns);
625
562
  return await batch.save({
626
563
  tag: storage.SaveOperationTag.INSERT,
627
564
  sourceTable: payload.sourceTable,
@@ -634,10 +571,10 @@ AND table_type = 'BASE TABLE';`,
634
571
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
635
572
  // "before" may be null if the replica id columns are unchanged
636
573
  // It's fine to treat that the same as an insert.
637
- const beforeUpdated = payload.previous_data
638
- ? common.toSQLiteRow(payload.previous_data, payload.columns)
574
+ const beforeUpdated = payload.previous_row
575
+ ? common.toSQLiteRow(payload.previous_row, payload.columns)
639
576
  : undefined;
640
- const after = common.toSQLiteRow(payload.data, payload.columns);
577
+ const after = common.toSQLiteRow(payload.row, payload.columns);
641
578
 
642
579
  return await batch.save({
643
580
  tag: storage.SaveOperationTag.UPDATE,
@@ -652,7 +589,7 @@ AND table_type = 'BASE TABLE';`,
652
589
 
653
590
  case storage.SaveOperationTag.DELETE:
654
591
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
655
- const beforeDeleted = common.toSQLiteRow(payload.data, payload.columns);
592
+ const beforeDeleted = common.toSQLiteRow(payload.row, payload.columns);
656
593
 
657
594
  return await batch.save({
658
595
  tag: storage.SaveOperationTag.DELETE,
@@ -666,12 +603,25 @@ AND table_type = 'BASE TABLE';`,
666
603
  return null;
667
604
  }
668
605
  }
669
- }
670
606
 
671
- async function tryRollback(promiseConnection: mysqlPromise.Connection) {
672
- try {
673
- await promiseConnection.query('ROLLBACK');
674
- } catch (e) {
675
- logger.error('Failed to rollback transaction', e);
607
+ async getReplicationLagMillis(): Promise<number | undefined> {
608
+ if (this.oldestUncommittedChange == null) {
609
+ if (this.isStartingReplication) {
610
+ // We don't have anything to compute replication lag with yet.
611
+ return undefined;
612
+ } else {
613
+ // We don't have any uncommitted changes, so replication is up-to-date.
614
+ return 0;
615
+ }
616
+ }
617
+ return Date.now() - this.oldestUncommittedChange.getTime();
618
+ }
619
+
620
+ async tryRollback(promiseConnection: mysqlPromise.Connection) {
621
+ try {
622
+ await promiseConnection.query('ROLLBACK');
623
+ } catch (e) {
624
+ this.logger.error('Failed to rollback transaction', e);
625
+ }
676
626
  }
677
627
  }
@@ -2,8 +2,8 @@ import { NormalizedMySQLConnectionConfig } from '../types/types.js';
2
2
  import mysqlPromise from 'mysql2/promise';
3
3
  import mysql, { FieldPacket, RowDataPacket } from 'mysql2';
4
4
  import * as mysql_utils from '../utils/mysql-utils.js';
5
- import ZongJi from '@powersync/mysql-zongji';
6
5
  import { logger } from '@powersync/lib-services-framework';
6
+ import { ZongJi } from '@powersync/mysql-zongji';
7
7
 
8
8
  export class MySQLConnectionManager {
9
9
  /**
@@ -46,6 +46,7 @@ export class MySQLConnectionManager {
46
46
  createBinlogListener(): ZongJi {
47
47
  const listener = new ZongJi({
48
48
  host: this.options.hostname,
49
+ port: this.options.port,
49
50
  user: this.options.username,
50
51
  password: this.options.password
51
52
  });