@powersync/service-module-mysql 0.0.0-dev-20260803144812 → 0.0.0-dev-20260929095716

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 (50) hide show
  1. package/CHANGELOG.md +76 -5
  2. package/ci/test-connection.yaml +12 -0
  3. package/dev/config/sync_rules.yaml +8 -4
  4. package/dist/api/MySQLRouteAPIAdapter.js +3 -3
  5. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
  6. package/dist/common/ReplicatedGTID.d.ts +16 -9
  7. package/dist/common/ReplicatedGTID.js +49 -25
  8. package/dist/common/ReplicatedGTID.js.map +1 -1
  9. package/dist/common/check-source-configuration.js +11 -0
  10. package/dist/common/check-source-configuration.js.map +1 -1
  11. package/dist/common/read-executed-gtid.d.ts +11 -1
  12. package/dist/common/read-executed-gtid.js +67 -5
  13. package/dist/common/read-executed-gtid.js.map +1 -1
  14. package/dist/module/MySQLModule.d.ts +0 -1
  15. package/dist/module/MySQLModule.js +0 -1
  16. package/dist/module/MySQLModule.js.map +1 -1
  17. package/dist/replication/BinLogReplicationJob.js +3 -1
  18. package/dist/replication/BinLogReplicationJob.js.map +1 -1
  19. package/dist/replication/BinLogStream.d.ts +8 -0
  20. package/dist/replication/BinLogStream.js +58 -18
  21. package/dist/replication/BinLogStream.js.map +1 -1
  22. package/dist/replication/MySQLConnectionManager.js +6 -0
  23. package/dist/replication/MySQLConnectionManager.js.map +1 -1
  24. package/dist/replication/zongji/BinLogListener.d.ts +41 -2
  25. package/dist/replication/zongji/BinLogListener.js +109 -31
  26. package/dist/replication/zongji/BinLogListener.js.map +1 -1
  27. package/dist/utils/mysql-utils.d.ts +6 -0
  28. package/dist/utils/mysql-utils.js +10 -0
  29. package/dist/utils/mysql-utils.js.map +1 -1
  30. package/package.json +10 -10
  31. package/src/api/MySQLRouteAPIAdapter.ts +4 -4
  32. package/src/common/ReplicatedGTID.ts +65 -32
  33. package/src/common/check-source-configuration.ts +15 -0
  34. package/src/common/read-executed-gtid.ts +76 -6
  35. package/src/module/MySQLModule.ts +0 -2
  36. package/src/replication/BinLogReplicationJob.ts +3 -1
  37. package/src/replication/BinLogStream.ts +63 -18
  38. package/src/replication/MySQLConnectionManager.ts +6 -0
  39. package/src/replication/zongji/BinLogListener.ts +136 -31
  40. package/src/utils/mysql-utils.ts +11 -0
  41. package/test/src/BinLogListener.test.ts +140 -1
  42. package/test/src/BinLogListener_queue.test.ts +62 -0
  43. package/test/src/BinlogStreamUtils.ts +4 -4
  44. package/test/src/ReplicatedGTID.test.ts +138 -0
  45. package/test/src/check-source-configuration.test.ts +70 -0
  46. package/test/src/mysql-utils.test.ts +19 -1
  47. package/test/src/read-executed-gtid.test.ts +188 -0
  48. package/test/src/schema-changes.test.ts +54 -36
  49. package/test/src/util.ts +27 -2
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -319,10 +319,11 @@ function defineTests(config: storage.TestStorageConfig) {
319
319
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t1','test1')`);
320
320
 
321
321
  await context.replicateSnapshot();
322
- await context.startStreaming();
323
322
 
324
323
  await connectionManager.query(`ALTER TABLE test_data ADD COLUMN new_column TEXT`);
325
324
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t2','test2')`);
325
+ // Replay the DDL after the insert commits so the replacement snapshot includes it.
326
+ await context.startStreaming();
326
327
 
327
328
  const data = await context.getBucketData('global[]');
328
329
 
@@ -349,43 +350,54 @@ function defineTests(config: storage.TestStorageConfig) {
349
350
  ]);
350
351
  });
351
352
 
352
- test('Change Replication Identity from full to index by adding a unique constraint', async () => {
353
- await using context = await BinlogStreamTestContext.open(factory);
354
- // Change replica id full by adding a unique index that can serve as the replication id
353
+ test.each([true, false])(
354
+ 'Change Replication Identity from full to index by adding a unique constraint (insert in snapshot: %s)',
355
+ async (insertInSnapshot) => {
356
+ await using context = await BinlogStreamTestContext.open(factory);
357
+ // Change replica id full by adding a unique index that can serve as the replication id
355
358
 
356
- await context.updateSyncRules(BASIC_SYNC_RULES);
359
+ await context.updateSyncRules(BASIC_SYNC_RULES);
357
360
 
358
- const { connectionManager } = context;
359
- // No primary key, no unique column, so full replication identity will be used
360
- await connectionManager.query(`CREATE TABLE test_data (id CHAR(36), description TEXT)`);
361
- await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t1','test1')`);
361
+ const { connectionManager } = context;
362
+ // No primary key, no unique column, so full replication identity will be used
363
+ await connectionManager.query(`CREATE TABLE test_data (id CHAR(36), description TEXT)`);
364
+ await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t1','test1')`);
362
365
 
363
- await context.replicateSnapshot();
364
- await context.startStreaming();
366
+ await context.replicateSnapshot();
365
367
 
366
- await connectionManager.query(`ALTER TABLE test_data ADD UNIQUE (id)`);
367
- await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t2','test2')`);
368
+ await connectionManager.query(`ALTER TABLE test_data ADD UNIQUE (id)`);
369
+ if (!insertInSnapshot) {
370
+ await context.startStreaming();
371
+ // Wait for the replacement snapshot before inserting the new row.
372
+ await context.getCheckpoint();
373
+ }
374
+ await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t2','test2')`);
375
+ if (insertInSnapshot) {
376
+ // Commit the insert before replaying the DDL so the snapshot must include it.
377
+ await context.startStreaming();
378
+ }
368
379
 
369
- const data = await context.getBucketData('global[]');
370
- const reduced = test_utils.reduceBucket(data).slice(1);
371
- expect(reduced.sort(compareIds)).toMatchObject([PUT_T1, PUT_T2]);
380
+ const data = await context.getBucketData('global[]');
381
+ const reduced = test_utils.reduceBucket(data).slice(1);
382
+ expect(reduced.sort(compareIds)).toMatchObject([PUT_T1, PUT_T2]);
372
383
 
373
- expect(data.slice(0, 2)).toMatchObject([
374
- // Initial inserts
375
- PUT_T1,
376
- // Truncate
377
- REMOVE_T1
378
- ]);
384
+ expect(data.slice(0, 2)).toMatchObject([
385
+ // Initial inserts
386
+ PUT_T1,
387
+ // Truncate
388
+ REMOVE_T1
389
+ ]);
379
390
 
380
- // Snapshot - order doesn't matter
381
- expect(data.slice(2)).toMatchObject([
382
- // Snapshot inserts
383
- PUT_T1,
384
- PUT_T2,
385
- // Replicated insert
386
- PUT_T2
387
- ]);
388
- });
391
+ // Snapshot - order doesn't matter
392
+ expect(data.slice(2)).toMatchObject([
393
+ // Snapshot inserts
394
+ PUT_T1,
395
+ ...(insertInSnapshot ? [PUT_T2] : []),
396
+ // Replicated insert
397
+ PUT_T2
398
+ ]);
399
+ }
400
+ );
389
401
 
390
402
  test('Change Replication Identity from full to index by adding a unique index', async () => {
391
403
  await using context = await BinlogStreamTestContext.open(factory);
@@ -398,10 +410,11 @@ function defineTests(config: storage.TestStorageConfig) {
398
410
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t1','test1')`);
399
411
 
400
412
  await context.replicateSnapshot();
401
- await context.startStreaming();
402
413
 
403
414
  await connectionManager.query(`CREATE UNIQUE INDEX id_idx ON test_data (id)`);
404
415
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t2','test2')`);
416
+ // Replay the DDL after the insert commits so the replacement snapshot includes it.
417
+ await context.startStreaming();
405
418
 
406
419
  const data = await context.getBucketData('global[]');
407
420
  const reduced = test_utils.reduceBucket(data).slice(1);
@@ -436,10 +449,11 @@ function defineTests(config: storage.TestStorageConfig) {
436
449
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t1','test1')`);
437
450
 
438
451
  await context.replicateSnapshot();
439
- await context.startStreaming();
440
452
 
441
453
  await connectionManager.query(`ALTER TABLE test_data DROP INDEX id`);
442
454
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t2','test2')`);
455
+ // Replay the DDL after the insert commits so the replacement snapshot includes it.
456
+ await context.startStreaming();
443
457
 
444
458
  const data = await context.getBucketData('global[]');
445
459
  const reduced = test_utils.reduceBucket(data).slice(1);
@@ -471,10 +485,11 @@ function defineTests(config: storage.TestStorageConfig) {
471
485
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t1','test1')`);
472
486
 
473
487
  await context.replicateSnapshot();
474
- await context.startStreaming();
475
488
 
476
489
  await connectionManager.query(`ALTER TABLE test_data MODIFY COLUMN id VARCHAR(36)`);
477
490
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t2','test2')`);
491
+ // Replay the DDL after the insert commits so the replacement snapshot includes it.
492
+ await context.startStreaming();
478
493
 
479
494
  const data = await context.getBucketData('global[]');
480
495
  const reduced = test_utils.reduceBucket(data).slice(1);
@@ -513,8 +528,11 @@ function defineTests(config: storage.TestStorageConfig) {
513
528
  await context.startStreaming();
514
529
 
515
530
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t2','test2')`);
531
+ await context.getCheckpoint();
516
532
 
517
533
  await connectionManager.query(`ALTER TABLE test_data MODIFY COLUMN id VARCHAR(36)`);
534
+ // Finish the replacement snapshot before inserting t3, which is streamed once.
535
+ await context.getCheckpoint();
518
536
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('t3','test3')`);
519
537
 
520
538
  const data = await context.getBucketData('global[]');
@@ -535,9 +553,9 @@ function defineTests(config: storage.TestStorageConfig) {
535
553
  ]);
536
554
 
537
555
  // Snapshot - order doesn't matter
538
- expect(data.slice(4, 7).sort(compareIds)).toMatchObject([PUT_T1, PUT_T2, PUT_T3]);
556
+ expect(data.slice(4, 6).sort(compareIds)).toMatchObject([PUT_T1, PUT_T2]);
539
557
 
540
- expect(data.slice(7).sort(compareIds)).toMatchObject([
558
+ expect(data.slice(6).sort(compareIds)).toMatchObject([
541
559
  // Replicated insert
542
560
  PUT_T3
543
561
  ]);
package/test/src/util.ts CHANGED
@@ -9,7 +9,7 @@ import * as mongo_storage from '@powersync/service-module-mongodb-storage';
9
9
  import * as postgres_storage from '@powersync/service-module-postgres-storage';
10
10
  import { TablePattern } from '@powersync/service-sync-rules';
11
11
  import mysqlPromise from 'mysql2/promise';
12
- import { describe, TestOptions } from 'vitest';
12
+ import { describe, TestOptions, vi } from 'vitest';
13
13
  import { env } from './env.js';
14
14
 
15
15
  export const TEST_URI = env.MYSQL_TEST_URI;
@@ -28,6 +28,17 @@ export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.
28
28
  url: env.PG_STORAGE_TEST_URL
29
29
  });
30
30
 
31
+ export function createMockMySQLConnection(queryHandler: (sql: string, params?: unknown[]) => Promise<unknown>): {
32
+ connection: mysqlPromise.Connection;
33
+ query: ReturnType<typeof vi.fn>;
34
+ } {
35
+ const query = vi.fn(queryHandler);
36
+ return {
37
+ connection: { query } as unknown as mysqlPromise.Connection,
38
+ query
39
+ };
40
+ }
41
+
31
42
  export function describeWithStorage(options: TestOptions, fn: (factory: TestStorageConfig) => void) {
32
43
  describe.skipIf(!env.TEST_MONGO_STORAGE)(`mongodb storage`, options, function () {
33
44
  fn(INITIALIZED_MONGO_STORAGE_FACTORY);
@@ -71,11 +82,19 @@ export async function getFromGTID(connectionManager: MySQLConnectionManager) {
71
82
  return fromGTID;
72
83
  }
73
84
 
85
+ export async function getActiveServerUuid(connectionManager: MySQLConnectionManager) {
86
+ const connection = await connectionManager.getConnection();
87
+ const activeServerUuid = await common.readServerUuid(connection);
88
+ connection.release();
89
+ return activeServerUuid;
90
+ }
91
+
74
92
  export interface CreateBinlogListenerParams {
75
93
  connectionManager: MySQLConnectionManager;
76
94
  eventHandler: BinLogEventHandler;
77
95
  sourceTables: TablePattern[];
78
96
  startGTID?: common.ReplicatedGTID;
97
+ ctrlConnectionProbeTimeoutMs?: number;
79
98
  }
80
99
  export async function createBinlogListener(params: CreateBinlogListenerParams): Promise<BinLogListener> {
81
100
  let { connectionManager, eventHandler, sourceTables, startGTID } = params;
@@ -84,12 +103,16 @@ export async function createBinlogListener(params: CreateBinlogListenerParams):
84
103
  startGTID = await getFromGTID(connectionManager);
85
104
  }
86
105
 
106
+ const activeServerUuid = await getActiveServerUuid(connectionManager);
107
+
87
108
  return new BinLogListener({
88
109
  connectionManager: connectionManager,
89
110
  eventHandler: eventHandler,
90
111
  startGTID: startGTID!,
91
112
  sourceTables: sourceTables,
92
- serverId: createRandomServerId(1)
113
+ serverId: createRandomServerId(1),
114
+ activeServerUuid: activeServerUuid,
115
+ ctrlConnectionProbeTimeoutMs: params.ctrlConnectionProbeTimeoutMs
93
116
  });
94
117
  }
95
118
 
@@ -100,6 +123,7 @@ export class TestBinLogEventHandler implements BinLogEventHandler {
100
123
  commitCount = 0;
101
124
  schemaChanges: SchemaChange[] = [];
102
125
  lastKeepAlive: string | undefined;
126
+ lastCommitLsn: string | undefined;
103
127
 
104
128
  unpause: ((value: void | PromiseLike<void>) => void) | undefined;
105
129
  private pausedPromise: Promise<void> | undefined;
@@ -127,6 +151,7 @@ export class TestBinLogEventHandler implements BinLogEventHandler {
127
151
 
128
152
  async onCommit(lsn: string) {
129
153
  this.commitCount++;
154
+ this.lastCommitLsn = lsn;
130
155
  }
131
156
 
132
157
  async onSchemaChange(change: SchemaChange) {