@powersync/service-module-mysql 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +3 -3
  3. package/dev/docker/mysql/init-scripts/my.cnf +1 -3
  4. package/dist/api/MySQLRouteAPIAdapter.js +12 -4
  5. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
  6. package/dist/common/ReplicatedGTID.js +4 -0
  7. package/dist/common/ReplicatedGTID.js.map +1 -1
  8. package/dist/common/common-index.d.ts +1 -2
  9. package/dist/common/common-index.js +1 -2
  10. package/dist/common/common-index.js.map +1 -1
  11. package/dist/common/mysql-to-sqlite.d.ts +1 -1
  12. package/dist/common/mysql-to-sqlite.js +4 -0
  13. package/dist/common/mysql-to-sqlite.js.map +1 -1
  14. package/dist/common/schema-utils.d.ts +20 -0
  15. package/dist/common/{get-replication-columns.js → schema-utils.js} +73 -30
  16. package/dist/common/schema-utils.js.map +1 -0
  17. package/dist/replication/BinLogReplicationJob.js +4 -1
  18. package/dist/replication/BinLogReplicationJob.js.map +1 -1
  19. package/dist/replication/BinLogStream.d.ts +9 -6
  20. package/dist/replication/BinLogStream.js +117 -73
  21. package/dist/replication/BinLogStream.js.map +1 -1
  22. package/dist/replication/zongji/BinLogListener.d.ts +60 -6
  23. package/dist/replication/zongji/BinLogListener.js +347 -89
  24. package/dist/replication/zongji/BinLogListener.js.map +1 -1
  25. package/dist/replication/zongji/zongji-utils.d.ts +4 -1
  26. package/dist/replication/zongji/zongji-utils.js +9 -0
  27. package/dist/replication/zongji/zongji-utils.js.map +1 -1
  28. package/dist/types/node-sql-parser-extended-types.d.ts +31 -0
  29. package/dist/types/node-sql-parser-extended-types.js +2 -0
  30. package/dist/types/node-sql-parser-extended-types.js.map +1 -0
  31. package/dist/utils/mysql-utils.d.ts +4 -2
  32. package/dist/utils/mysql-utils.js +15 -3
  33. package/dist/utils/mysql-utils.js.map +1 -1
  34. package/dist/utils/parser-utils.d.ts +16 -0
  35. package/dist/utils/parser-utils.js +58 -0
  36. package/dist/utils/parser-utils.js.map +1 -0
  37. package/package.json +12 -11
  38. package/src/api/MySQLRouteAPIAdapter.ts +15 -4
  39. package/src/common/ReplicatedGTID.ts +6 -1
  40. package/src/common/common-index.ts +1 -2
  41. package/src/common/mysql-to-sqlite.ts +7 -1
  42. package/src/common/{get-replication-columns.ts → schema-utils.ts} +96 -37
  43. package/src/replication/BinLogReplicationJob.ts +4 -1
  44. package/src/replication/BinLogStream.ts +139 -94
  45. package/src/replication/zongji/BinLogListener.ts +421 -100
  46. package/src/replication/zongji/zongji-utils.ts +16 -1
  47. package/src/types/node-sql-parser-extended-types.ts +25 -0
  48. package/src/utils/mysql-utils.ts +19 -4
  49. package/src/utils/parser-utils.ts +73 -0
  50. package/test/src/BinLogListener.test.ts +421 -77
  51. package/test/src/BinLogStream.test.ts +128 -52
  52. package/test/src/BinlogStreamUtils.ts +12 -2
  53. package/test/src/mysql-to-sqlite.test.ts +5 -5
  54. package/test/src/parser-utils.test.ts +24 -0
  55. package/test/src/schema-changes.test.ts +659 -0
  56. package/test/src/util.ts +87 -1
  57. package/tsconfig.tsbuildinfo +1 -1
  58. package/dist/common/get-replication-columns.d.ts +0 -12
  59. package/dist/common/get-replication-columns.js.map +0 -1
  60. package/dist/common/get-tables-from-pattern.d.ts +0 -7
  61. package/dist/common/get-tables-from-pattern.js +0 -28
  62. package/dist/common/get-tables-from-pattern.js.map +0 -1
  63. package/src/common/get-tables-from-pattern.ts +0 -44
@@ -10,7 +10,9 @@ import {
10
10
  ColumnDescriptor,
11
11
  framework,
12
12
  getUuidReplicaIdentityBson,
13
+ InternalOpId,
13
14
  MetricsEngine,
15
+ SourceTable,
14
16
  storage
15
17
  } from '@powersync/service-core';
16
18
  import mysql from 'mysql2';
@@ -18,10 +20,10 @@ import mysqlPromise from 'mysql2/promise';
18
20
 
19
21
  import { TableMapEntry } from '@powersync/mysql-zongji';
20
22
  import * as common from '../common/common-index.js';
21
- import { createRandomServerId, escapeMysqlTableName } from '../utils/mysql-utils.js';
23
+ import { createRandomServerId, qualifiedMySQLTable } from '../utils/mysql-utils.js';
22
24
  import { MySQLConnectionManager } from './MySQLConnectionManager.js';
23
25
  import { ReplicationMetric } from '@powersync/service-types';
24
- import { BinLogEventHandler, BinLogListener, Row } from './zongji/BinLogListener.js';
26
+ import { BinLogEventHandler, BinLogListener, Row, SchemaChange, SchemaChangeType } from './zongji/BinLogListener.js';
25
27
 
26
28
  export interface BinLogStreamOptions {
27
29
  connections: MySQLConnectionManager;
@@ -31,11 +33,6 @@ export interface BinLogStreamOptions {
31
33
  logger?: Logger;
32
34
  }
33
35
 
34
- interface MysqlRelId {
35
- schema: string;
36
- name: string;
37
- }
38
-
39
36
  interface WriteChangePayload {
40
37
  type: storage.SaveOperationTag;
41
38
  row: Row;
@@ -53,11 +50,14 @@ export class BinlogConfigurationError extends Error {
53
50
  }
54
51
 
55
52
  /**
56
- * MySQL does not have same relation structure. Just returning unique key as string.
57
- * @param source
53
+ * Unlike Postgres' relation id, MySQL's tableId is only guaranteed to be unique and stay the same
54
+ * in the context of a single replication session.
55
+ * Instead, we create a unique key by combining the source schema and table name
56
+ * @param schema
57
+ * @param tableName
58
58
  */
59
- function getMysqlRelId(source: MysqlRelId): string {
60
- return `${source.schema}.${source.name}`;
59
+ function createTableId(schema: string, tableName: string): string {
60
+ return `${schema}.${tableName}`;
61
61
  }
62
62
 
63
63
  export class BinLogStream {
@@ -68,11 +68,11 @@ export class BinLogStream {
68
68
 
69
69
  private readonly connections: MySQLConnectionManager;
70
70
 
71
- private abortSignal: AbortSignal;
71
+ private readonly abortSignal: AbortSignal;
72
72
 
73
- private tableCache = new Map<string | number, storage.SourceTable>();
73
+ private readonly logger: Logger;
74
74
 
75
- private logger: Logger;
75
+ private tableCache = new Map<string | number, storage.SourceTable>();
76
76
 
77
77
  /**
78
78
  * Time of the oldest uncommitted change, according to the source db.
@@ -83,7 +83,7 @@ export class BinLogStream {
83
83
  * Keep track of whether we have done a commit or keepalive yet.
84
84
  * We can only compute replication lag if isStartingReplication == false, or oldestUncommittedChange is present.
85
85
  */
86
- private isStartingReplication = true;
86
+ isStartingReplication = true;
87
87
 
88
88
  constructor(private options: BinLogStreamOptions) {
89
89
  this.logger = options.logger ?? defaultLogger;
@@ -134,15 +134,15 @@ export class BinLogStream {
134
134
  entity_descriptor: entity,
135
135
  sync_rules: this.syncRules
136
136
  });
137
- // objectId is always defined for mysql
137
+ // Since we create the objectId ourselves, this is always defined
138
138
  this.tableCache.set(entity.objectId!, result.table);
139
139
 
140
- // Drop conflicting tables. This includes for example renamed tables.
140
+ // Drop conflicting tables. In the MySQL case with ObjectIds created from the table name, renames cannot be detected by the storage.
141
141
  await batch.drop(result.dropTables);
142
142
 
143
143
  // Snapshot if:
144
144
  // 1. Snapshot is requested (false for initial snapshot, since that process handles it elsewhere)
145
- // 2. Snapshot is not already done, AND:
145
+ // 2. Snapshot is not done yet, AND:
146
146
  // 3. The table is used in sync rules.
147
147
  const shouldSnapshot = snapshot && !result.table.snapshotComplete && result.table.syncAny;
148
148
 
@@ -158,10 +158,10 @@ export class BinLogStream {
158
158
  const promiseConnection = (connection as mysql.Connection).promise();
159
159
  try {
160
160
  await promiseConnection.query(`SET time_zone = '+00:00'`);
161
- await promiseConnection.query('BEGIN');
161
+ await promiseConnection.query('START TRANSACTION');
162
162
  try {
163
163
  gtid = await common.readExecutedGtid(promiseConnection);
164
- await this.snapshotTable(connection.connection, batch, result.table);
164
+ await this.snapshotTable(connection as mysql.Connection, batch, result.table);
165
165
  await promiseConnection.query('COMMIT');
166
166
  } catch (e) {
167
167
  await this.tryRollback(promiseConnection);
@@ -185,62 +185,21 @@ export class BinLogStream {
185
185
  return [];
186
186
  }
187
187
 
188
- let tableRows: any[];
189
- const prefix = tablePattern.isWildcard ? tablePattern.tablePrefix : undefined;
190
- if (tablePattern.isWildcard) {
191
- const result = await this.connections.query(
192
- `SELECT TABLE_NAME
193
- FROM information_schema.tables
194
- WHERE TABLE_SCHEMA = ? AND TABLE_NAME LIKE ?;
195
- `,
196
- [tablePattern.schema, tablePattern.tablePattern]
197
- );
198
- tableRows = result[0];
199
- } else {
200
- const result = await this.connections.query(
201
- `SELECT TABLE_NAME
202
- FROM information_schema.tables
203
- WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?;
204
- `,
205
- [tablePattern.schema, tablePattern.tablePattern]
206
- );
207
- tableRows = result[0];
208
- }
209
- let tables: storage.SourceTable[] = [];
210
-
211
- for (let row of tableRows) {
212
- const name = row['TABLE_NAME'] as string;
213
- if (prefix && !name.startsWith(prefix)) {
214
- continue;
215
- }
216
-
217
- const result = await this.connections.query(
218
- `SELECT 1
219
- FROM information_schema.tables
220
- WHERE table_schema = ? AND table_name = ?
221
- AND table_type = 'BASE TABLE';`,
222
- [tablePattern.schema, tablePattern.name]
223
- );
224
- if (result[0].length == 0) {
225
- this.logger.info(`Skipping ${tablePattern.schema}.${name} - no table exists/is not a base table`);
226
- continue;
227
- }
188
+ const connection = await this.connections.getConnection();
189
+ const matchedTables: string[] = await common.getTablesFromPattern(connection, tablePattern);
190
+ connection.release();
228
191
 
229
- const connection = await this.connections.getConnection();
230
- const replicationColumns = await common.getReplicationIdentityColumns({
231
- connection: connection,
232
- schema: tablePattern.schema,
233
- table_name: tablePattern.name
234
- });
235
- connection.release();
192
+ let tables: storage.SourceTable[] = [];
193
+ for (const matchedTable of matchedTables) {
194
+ const replicaIdColumns = await this.getReplicaIdColumns(matchedTable, tablePattern.schema);
236
195
 
237
196
  const table = await this.handleRelation(
238
197
  batch,
239
198
  {
240
- name,
199
+ name: matchedTable,
241
200
  schema: tablePattern.schema,
242
- objectId: getMysqlRelId(tablePattern),
243
- replicationColumns: replicationColumns.columns
201
+ objectId: createTableId(tablePattern.schema, matchedTable),
202
+ replicaIdColumns: replicaIdColumns
244
203
  },
245
204
  false
246
205
  );
@@ -251,7 +210,7 @@ AND table_type = 'BASE TABLE';`,
251
210
  }
252
211
 
253
212
  /**
254
- * Checks if the initial sync has been completed yet.
213
+ * Checks if the initial sync has already been completed
255
214
  */
256
215
  protected async checkInitialReplicated(): Promise<boolean> {
257
216
  const status = await this.storage.getStatus();
@@ -260,7 +219,7 @@ AND table_type = 'BASE TABLE';`,
260
219
  this.logger.info(`Initial replication already done.`);
261
220
 
262
221
  if (lastKnowGTID) {
263
- // Check if the binlog is still available. If it isn't we need to snapshot again.
222
+ // Check if the specific binlog file is still available. If it isn't, we need to snapshot again.
264
223
  const connection = await this.connections.getConnection();
265
224
  try {
266
225
  const isAvailable = await common.isBinlogStillAvailable(connection, lastKnowGTID.position.filename);
@@ -294,6 +253,7 @@ AND table_type = 'BASE TABLE';`,
294
253
  const promiseConnection = (connection as mysql.Connection).promise();
295
254
  const headGTID = await common.readExecutedGtid(promiseConnection);
296
255
  this.logger.info(`Using snapshot checkpoint GTID: '${headGTID}'`);
256
+ let lastOp: InternalOpId | null = null;
297
257
  try {
298
258
  this.logger.info(`Starting initial replication`);
299
259
  await promiseConnection.query<mysqlPromise.RowDataPacket[]>(
@@ -303,7 +263,7 @@ AND table_type = 'BASE TABLE';`,
303
263
  await promiseConnection.query(`SET time_zone = '+00:00'`);
304
264
 
305
265
  const sourceTables = this.syncRules.getSourceTables();
306
- await this.storage.startBatch(
266
+ const flushResults = await this.storage.startBatch(
307
267
  {
308
268
  logger: this.logger,
309
269
  zeroLSN: common.ReplicatedGTID.ZERO.comparable,
@@ -322,6 +282,7 @@ AND table_type = 'BASE TABLE';`,
322
282
  await batch.commit(headGTID.comparable);
323
283
  }
324
284
  );
285
+ lastOp = flushResults?.flushed_op ?? null;
325
286
  this.logger.info(`Initial replication done`);
326
287
  await promiseConnection.query('COMMIT');
327
288
  } catch (e) {
@@ -330,6 +291,15 @@ AND table_type = 'BASE TABLE';`,
330
291
  } finally {
331
292
  connection.release();
332
293
  }
294
+
295
+ if (lastOp != null) {
296
+ // Populate the cache _after_ initial replication, but _before_ we switch to this sync rules.
297
+ await this.storage.populatePersistentChecksumCache({
298
+ // No checkpoint yet, but we do have the opId.
299
+ maxOpId: lastOp,
300
+ signal: this.abortSignal
301
+ });
302
+ }
333
303
  }
334
304
 
335
305
  private async snapshotTable(
@@ -337,11 +307,11 @@ AND table_type = 'BASE TABLE';`,
337
307
  batch: storage.BucketStorageBatch,
338
308
  table: storage.SourceTable
339
309
  ) {
340
- this.logger.info(`Replicating ${table.qualifiedName}`);
310
+ this.logger.info(`Replicating ${qualifiedMySQLTable(table)}`);
341
311
  // TODO count rows and log progress at certain batch sizes
342
312
 
343
313
  // MAX_EXECUTION_TIME(0) hint disables execution timeout for this query
344
- const query = connection.query(`SELECT /*+ MAX_EXECUTION_TIME(0) */ * FROM ${escapeMysqlTableName(table)}`);
314
+ const query = connection.query(`SELECT /*+ MAX_EXECUTION_TIME(0) */ * FROM ${qualifiedMySQLTable(table)}`);
345
315
  const stream = query.stream();
346
316
 
347
317
  let columns: Map<string, ColumnDescriptor> | undefined = undefined;
@@ -430,8 +400,6 @@ AND table_type = 'BASE TABLE';`,
430
400
  }
431
401
 
432
402
  async streamChanges() {
433
- // Auto-activate as soon as initial replication is done
434
- await this.storage.autoActivate();
435
403
  const serverId = createRandomServerId(this.storage.group_id);
436
404
 
437
405
  const connection = await this.connections.getConnection();
@@ -442,7 +410,6 @@ AND table_type = 'BASE TABLE';`,
442
410
  const fromGTID = checkpoint_lsn
443
411
  ? common.ReplicatedGTID.fromSerialized(checkpoint_lsn)
444
412
  : await common.readExecutedGtid(connection);
445
- const binLogPositionState = fromGTID.position;
446
413
  connection.release();
447
414
 
448
415
  if (!this.stopped) {
@@ -450,12 +417,10 @@ AND table_type = 'BASE TABLE';`,
450
417
  { zeroLSN: common.ReplicatedGTID.ZERO.comparable, defaultSchema: this.defaultSchema, storeCurrentData: true },
451
418
  async (batch) => {
452
419
  const binlogEventHandler = this.createBinlogEventHandler(batch);
453
- // Only listen for changes to tables in the sync rules
454
- const includedTables = [...this.tableCache.values()].map((table) => table.table);
455
420
  const binlogListener = new BinLogListener({
456
421
  logger: this.logger,
457
- includedTables: includedTables,
458
- startPosition: binLogPositionState,
422
+ sourceTables: this.syncRules.getSourceTables(),
423
+ startGTID: fromGTID,
459
424
  connectionManager: this.connections,
460
425
  serverId: serverId,
461
426
  eventHandler: binlogEventHandler
@@ -463,15 +428,14 @@ AND table_type = 'BASE TABLE';`,
463
428
 
464
429
  this.abortSignal.addEventListener(
465
430
  'abort',
466
- () => {
467
- this.logger.info('Abort signal received, stopping replication...');
468
- binlogListener.stop();
431
+ async () => {
432
+ await binlogListener.stop();
469
433
  },
470
434
  { once: true }
471
435
  );
472
436
 
473
- // Only returns when the replication is stopped or interrupted by an error
474
437
  await binlogListener.start();
438
+ await binlogListener.replicateUntilStopped();
475
439
  }
476
440
  );
477
441
  }
@@ -502,6 +466,12 @@ AND table_type = 'BASE TABLE';`,
502
466
  tableEntry: tableMap
503
467
  });
504
468
  },
469
+ onKeepAlive: async (lsn: string) => {
470
+ const didCommit = await batch.keepalive(lsn);
471
+ if (didCommit) {
472
+ this.oldestUncommittedChange = null;
473
+ }
474
+ },
505
475
  onCommit: async (lsn: string) => {
506
476
  this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED).add(1);
507
477
  const didCommit = await batch.commit(lsn, { oldestUncommittedChange: this.oldestUncommittedChange });
@@ -517,10 +487,82 @@ AND table_type = 'BASE TABLE';`,
517
487
  },
518
488
  onRotate: async () => {
519
489
  this.isStartingReplication = false;
490
+ },
491
+ onSchemaChange: async (change: SchemaChange) => {
492
+ await this.handleSchemaChange(batch, change);
520
493
  }
521
494
  };
522
495
  }
523
496
 
497
+ private async handleSchemaChange(batch: storage.BucketStorageBatch, change: SchemaChange): Promise<void> {
498
+ if (change.type === SchemaChangeType.RENAME_TABLE) {
499
+ const fromTableId = createTableId(change.schema, change.table);
500
+
501
+ const fromTable = this.tableCache.get(fromTableId);
502
+ // Old table needs to be cleaned up
503
+ if (fromTable) {
504
+ await batch.drop([fromTable]);
505
+ this.tableCache.delete(fromTableId);
506
+ }
507
+ // The new table matched a table in the sync rules
508
+ if (change.newTable) {
509
+ await this.handleCreateOrUpdateTable(batch, change.newTable!, change.schema);
510
+ }
511
+ } else {
512
+ const tableId = createTableId(change.schema, change.table);
513
+
514
+ const table = this.getTable(tableId);
515
+
516
+ switch (change.type) {
517
+ case SchemaChangeType.ALTER_TABLE_COLUMN:
518
+ case SchemaChangeType.REPLICATION_IDENTITY:
519
+ // For these changes, we need to update the table if the replication identity columns have changed.
520
+ await this.handleCreateOrUpdateTable(batch, change.table, change.schema);
521
+ break;
522
+ case SchemaChangeType.TRUNCATE_TABLE:
523
+ await batch.truncate([table]);
524
+ break;
525
+ case SchemaChangeType.DROP_TABLE:
526
+ await batch.drop([table]);
527
+ this.tableCache.delete(tableId);
528
+ break;
529
+ default:
530
+ // No action needed for other schema changes
531
+ break;
532
+ }
533
+ }
534
+ }
535
+
536
+ private async getReplicaIdColumns(tableName: string, schema: string) {
537
+ const connection = await this.connections.getConnection();
538
+ const replicaIdColumns = await common.getReplicationIdentityColumns({
539
+ connection,
540
+ schema,
541
+ tableName
542
+ });
543
+ connection.release();
544
+
545
+ return replicaIdColumns.columns;
546
+ }
547
+
548
+ private async handleCreateOrUpdateTable(
549
+ batch: storage.BucketStorageBatch,
550
+ tableName: string,
551
+ schema: string
552
+ ): Promise<SourceTable> {
553
+ const replicaIdColumns = await this.getReplicaIdColumns(tableName, schema);
554
+ return await this.handleRelation(
555
+ batch,
556
+ {
557
+ name: tableName,
558
+ schema: schema,
559
+ objectId: createTableId(schema, tableName),
560
+ replicaIdColumns: replicaIdColumns
561
+ },
562
+ true
563
+ );
564
+ }
565
+
524
566
  private async writeChanges(
525
567
  batch: storage.BucketStorageBatch,
526
568
  msg: {
@@ -531,17 +573,20 @@ AND table_type = 'BASE TABLE';`,
531
573
  }
532
574
  ): Promise<storage.FlushedResult | null> {
533
575
  const columns = common.toColumnDescriptors(msg.tableEntry);
576
+ const tableId = createTableId(msg.tableEntry.parentSchema, msg.tableEntry.tableName);
577
+
578
+ let table = this.tableCache.get(tableId);
579
+ if (table == null) {
580
+ // This is an insert for a new table that matches a table in the sync rules
581
+ // We need to create the table in the storage and cache it.
582
+ table = await this.handleCreateOrUpdateTable(batch, msg.tableEntry.tableName, msg.tableEntry.parentSchema);
583
+ }
534
584
 
535
585
  for (const [index, row] of msg.rows.entries()) {
536
586
  await this.writeChange(batch, {
537
587
  type: msg.type,
538
588
  database: msg.tableEntry.parentSchema,
539
- sourceTable: this.getTable(
540
- getMysqlRelId({
541
- schema: msg.tableEntry.parentSchema,
542
- name: msg.tableEntry.tableName
543
- })
544
- ),
589
+ sourceTable: table!,
545
590
  table: msg.tableEntry.tableName,
546
591
  columns: columns,
547
592
  row: row,
@@ -569,7 +614,7 @@ AND table_type = 'BASE TABLE';`,
569
614
  });
570
615
  case storage.SaveOperationTag.UPDATE:
571
616
  this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
572
- // "before" may be null if the replica id columns are unchanged
617
+ // The previous row may be null if the replica id columns are unchanged.
573
618
  // It's fine to treat that the same as an insert.
574
619
  const beforeUpdated = payload.previous_row
575
620
  ? common.toSQLiteRow(payload.previous_row, payload.columns)
@@ -610,7 +655,7 @@ AND table_type = 'BASE TABLE';`,
610
655
  // We don't have anything to compute replication lag with yet.
611
656
  return undefined;
612
657
  } else {
613
- // We don't have any uncommitted changes, so replication is up-to-date.
658
+ // We don't have any uncommitted changes, so replication is up to date.
614
659
  return 0;
615
660
  }
616
661
  }