@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
@@ -0,0 +1,62 @@
1
+ import { ReplicatedGTID } from '@module/common/ReplicatedGTID.js';
2
+ import { MySQLConnectionManager } from '@module/replication/MySQLConnectionManager.js';
3
+ import { BinLogListener } from '@module/replication/zongji/BinLogListener.js';
4
+ import { ZongJi } from '@powersync/mysql-zongji';
5
+ import { EventEmitter } from 'node:events';
6
+ import { setImmediate } from 'node:timers/promises';
7
+ import { expect, test, vi } from 'vitest';
8
+ import { TEST_CONNECTION_OPTIONS, TestBinLogEventHandler } from './util.js';
9
+
10
+ test.each(['before', 'after'] as const)('restarts when the queue drains %s Zongji stops', async (drainOrder) => {
11
+ const manager = new MySQLConnectionManager({ ...TEST_CONNECTION_OPTIONS, binlog_queue_memory_limit: 1 }, {});
12
+ const stopStarted = Promise.withResolvers<void>();
13
+ const processEvent = Promise.withResolvers<void>();
14
+ // Control the external listener's stop acknowledgement independently of the
15
+ // real processing queue: MySQL can acknowledge KILL after the queue drains.
16
+ const zongji = Object.assign(new EventEmitter(), {
17
+ stopped: false,
18
+ ctrlConnection: {},
19
+ stop() {
20
+ this.stopped = true;
21
+ stopStarted.resolve();
22
+ }
23
+ });
24
+ vi.spyOn(manager, 'createBinlogListener').mockImplementation(() => zongji as unknown as ZongJi);
25
+ const handler = new TestBinLogEventHandler();
26
+ vi.spyOn(handler, 'onKeepAlive').mockImplementation(() => processEvent.promise);
27
+ const listener = new BinLogListener({
28
+ connectionManager: manager,
29
+ eventHandler: handler,
30
+ sourceTables: [],
31
+ serverId: 1,
32
+ activeServerUuid: 'test',
33
+ startGTID: ReplicatedGTID.ZERO('test')
34
+ });
35
+ const restart = vi.spyOn(listener, 'start').mockResolvedValue();
36
+
37
+ try {
38
+ const drained = listener.processingQueue.drain();
39
+ zongji.emit('binlog', { getEventName: () => 'heartbeat', size: 1024 * 1024 });
40
+ await stopStarted.promise;
41
+ if (drainOrder === 'before') {
42
+ processEvent.resolve();
43
+ await drained;
44
+ expect(listener.processingQueue.idle()).toBe(true);
45
+ zongji.emit('stopped');
46
+ } else {
47
+ zongji.emit('stopped');
48
+ // Let stopZongji return while the queue worker is still blocked.
49
+ await setImmediate();
50
+ expect(listener.processingQueue.running()).toBe(1);
51
+ expect(restart).not.toHaveBeenCalled();
52
+ processEvent.resolve();
53
+ }
54
+ await vi.waitFor(() => expect(restart).toHaveBeenCalledExactlyOnceWith(true));
55
+ expect(listener.queueMemoryUsage).toBe(0);
56
+ } finally {
57
+ processEvent.resolve();
58
+ zongji.emit('stopped');
59
+ await listener.stop();
60
+ await manager.end();
61
+ }
62
+ });
@@ -15,7 +15,7 @@ import {
15
15
  SyncRulesBucketStorage,
16
16
  updateSyncRulesFromYaml
17
17
  } from '@powersync/service-core';
18
- import { bucketRequest, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
18
+ import { bucketRequest, getTestStorage, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
19
19
  import mysqlPromise from 'mysql2/promise';
20
20
  import timers from 'timers/promises';
21
21
  import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
@@ -75,7 +75,7 @@ export class BinlogStreamTestContext {
75
75
  updateSyncRulesFromYaml(content, { validate: true, storageVersion: LEGACY_STORAGE_VERSION })
76
76
  );
77
77
  this.syncRulesContent = replicationStream.syncConfigContent[0];
78
- this.storage = this.factory.getInstance(replicationStream);
78
+ this.storage = await getTestStorage(this.factory, replicationStream);
79
79
  return this.storage!;
80
80
  }
81
81
 
@@ -86,7 +86,7 @@ export class BinlogStreamTestContext {
86
86
  }
87
87
 
88
88
  this.syncRulesContent = syncConfig.content;
89
- this.storage = syncConfig.storage;
89
+ this.storage = await getTestStorage(this.factory, syncConfig.replicationStream);
90
90
  return this.storage!;
91
91
  }
92
92
 
@@ -97,7 +97,7 @@ export class BinlogStreamTestContext {
97
97
  }
98
98
 
99
99
  this.syncRulesContent = syncConfig.content;
100
- this.storage = syncConfig.storage;
100
+ this.storage = await getTestStorage(this.factory, syncConfig.replicationStream);
101
101
  this.replicationDone = true;
102
102
  return this.storage!;
103
103
  }
@@ -0,0 +1,138 @@
1
+ import { ReplicatedGTID } from '@module/common/ReplicatedGTID.js';
2
+ import * as uuid from 'uuid';
3
+ import { describe, expect, test } from 'vitest';
4
+
5
+ describe('ReplicatedGTID', () => {
6
+ const SERVER_UUID = 'a7d0ff7b-0c0e-11f0-8b38-566fbaa00004';
7
+ const POSITION = { filename: 'binlog.000042', offset: 1234 };
8
+
9
+ describe('single GTID', () => {
10
+ test('exposes its raw value, server UUID, and binlog position', () => {
11
+ const gtid = new ReplicatedGTID({
12
+ rawGtid: `${SERVER_UUID}:5`,
13
+ position: POSITION
14
+ });
15
+
16
+ expect(gtid.raw).toEqual(`${SERVER_UUID}:5`);
17
+ expect(gtid.serverUuid).toEqual(SERVER_UUID);
18
+ expect(gtid.position).toEqual(POSITION);
19
+ });
20
+
21
+ test('formats a comparable LSN using the transaction id', () => {
22
+ const gtid = new ReplicatedGTID({
23
+ rawGtid: `${SERVER_UUID}:17`,
24
+ position: POSITION
25
+ });
26
+
27
+ expect(gtid.comparable).toEqual(`0000000000000017|${SERVER_UUID}:17|binlog.000042|1234`);
28
+ expect(gtid.toString()).toEqual(gtid.comparable);
29
+ });
30
+
31
+ test('normalizes surrounding whitespace', () => {
32
+ const gtid = new ReplicatedGTID({
33
+ rawGtid: ` \n\t${SERVER_UUID}:17 \r\n`,
34
+ position: POSITION
35
+ });
36
+
37
+ expect(gtid.raw).toEqual(`${SERVER_UUID}:17`);
38
+ expect(gtid.serverUuid).toEqual(SERVER_UUID);
39
+ expect(gtid.comparable).toEqual(`0000000000000017|${SERVER_UUID}:17|binlog.000042|1234`);
40
+ });
41
+
42
+ test('keeps the ZERO GTID format stable', () => {
43
+ expect(ReplicatedGTID.ZERO(SERVER_UUID).raw).toEqual(`${SERVER_UUID}:0`);
44
+ expect(ReplicatedGTID.ZERO(SERVER_UUID).comparable).toEqual(`0000000000000000|${SERVER_UUID}:0||0`);
45
+ });
46
+ });
47
+
48
+ describe('validation', () => {
49
+ test.each([
50
+ ['', 'missing server UUID and transaction id'],
51
+ [SERVER_UUID, 'missing transaction id'],
52
+ [`${SERVER_UUID}:`, 'empty transaction id'],
53
+ [`:${17}`, 'empty server UUID'],
54
+ [`${SERVER_UUID}:1-17`, 'transaction interval'],
55
+ [`${SERVER_UUID}:1:17`, 'multiple transaction components'],
56
+ [`${SERVER_UUID}:abc`, 'non-numeric transaction id'],
57
+ [`${SERVER_UUID}:-1`, 'negative transaction id'],
58
+ [`${SERVER_UUID}:17,another-server:9`, 'comma-separated GTID set'],
59
+ [`${SERVER_UUID}:17,\nanother-server:9`, 'newline-separated GTID set']
60
+ ])('rejects %s (%s)', (rawGtid) => {
61
+ expect(() => new ReplicatedGTID({ rawGtid, position: POSITION })).toThrow();
62
+ });
63
+ });
64
+
65
+ describe('serialization', () => {
66
+ test('round-trips a single GTID', () => {
67
+ const gtid = new ReplicatedGTID({
68
+ rawGtid: `${SERVER_UUID}:17`,
69
+ position: POSITION
70
+ });
71
+
72
+ const deserialized = ReplicatedGTID.fromSerialized(gtid.comparable);
73
+
74
+ expect(deserialized.raw).toEqual(gtid.raw);
75
+ expect(deserialized.serverUuid).toEqual(SERVER_UUID);
76
+ expect(deserialized.position).toEqual(POSITION);
77
+ expect(deserialized.comparable).toEqual(gtid.comparable);
78
+ });
79
+
80
+ test('throws on malformed serialized GTIDs', () => {
81
+ expect(() => ReplicatedGTID.fromSerialized('abc')).toThrow('Invalid serialized GTID');
82
+ expect(() => ReplicatedGTID.fromSerialized(`0000000000000001|${SERVER_UUID}:1|binlog.000001`)).toThrow(
83
+ 'Invalid serialized GTID'
84
+ );
85
+ expect(() => ReplicatedGTID.fromSerialized(`0000000000000001|${SERVER_UUID}:1|binlog.000001|notanumber`)).toThrow(
86
+ 'Invalid BinLog offset'
87
+ );
88
+ });
89
+
90
+ test('rejects a serialized GTID set', () => {
91
+ const serialized = `0000000000000017|${SERVER_UUID}:1-17|binlog.000042|1234`;
92
+
93
+ expect(() => ReplicatedGTID.fromSerialized(serialized)).toThrow('Expected a single transaction id');
94
+ });
95
+ });
96
+
97
+ describe('binlog events', () => {
98
+ test('creates a single GTID from a binlog event', () => {
99
+ const gtid = ReplicatedGTID.fromBinLogEvent({
100
+ rawGtid: {
101
+ serverUuid: Buffer.from(uuid.parse(SERVER_UUID)),
102
+ transactionId: 17
103
+ },
104
+ position: POSITION
105
+ });
106
+
107
+ expect(gtid.raw).toEqual(`${SERVER_UUID}:17`);
108
+ expect(gtid.position).toEqual(POSITION);
109
+ expect(gtid.comparable).toEqual(`0000000000000017|${SERVER_UUID}:17|binlog.000042|1234`);
110
+ });
111
+ });
112
+
113
+ describe('LSN ordering', () => {
114
+ test('orders GTIDs from the same server by transaction id', () => {
115
+ const earlier = new ReplicatedGTID({ rawGtid: `${SERVER_UUID}:9`, position: POSITION });
116
+ const later = new ReplicatedGTID({ rawGtid: `${SERVER_UUID}:18`, position: POSITION });
117
+
118
+ expect(earlier.comparable < later.comparable).toBeTruthy();
119
+ });
120
+
121
+ test('orders LSNs for the same transaction by binlog offset', () => {
122
+ // The binlog offset is not zero-padded, so lexicographic ordering only holds for
123
+ // offsets with the same number of digits. This format cannot change while existing
124
+ // LSNs remain persisted in bucket storage.
125
+ const rawGtid = `${SERVER_UUID}:18`;
126
+ const transactionStart = new ReplicatedGTID({
127
+ rawGtid,
128
+ position: { filename: 'binlog.000042', offset: 157 }
129
+ });
130
+ const transactionEnd = new ReplicatedGTID({
131
+ rawGtid,
132
+ position: { filename: 'binlog.000042', offset: 300 }
133
+ });
134
+
135
+ expect(transactionStart.comparable < transactionEnd.comparable).toBeTruthy();
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,70 @@
1
+ import { checkSourceConfiguration } from '@module/common/check-source-configuration.js';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { createMockMySQLConnection } from './util.js';
4
+
5
+ describe('checkSourceConfiguration', () => {
6
+ test('accepts a primary MySQL server', async () => {
7
+ const { connection, query } = createConnection({ version: '8.4.0', replicaStatuses: [] });
8
+
9
+ await expect(checkSourceConfiguration(connection)).resolves.toEqual([]);
10
+ expect(query).toHaveBeenCalledWith('SHOW REPLICA STATUS', []);
11
+ });
12
+
13
+ test('rejects a replica on MySQL 8.0.22 and later', async () => {
14
+ const { connection, query } = createConnection({
15
+ version: '8.0.22',
16
+ replicaStatuses: [{ Channel_Name: '' }]
17
+ });
18
+
19
+ await expect(checkSourceConfiguration(connection)).resolves.toContain(
20
+ 'Connecting PowerSync to a MySQL replica is not supported. Please connect PowerSync directly to the primary server.'
21
+ );
22
+ expect(query).toHaveBeenCalledWith('SHOW REPLICA STATUS', []);
23
+ expect(query).not.toHaveBeenCalledWith('SHOW SLAVE STATUS', []);
24
+ });
25
+
26
+ test('uses legacy replica-status syntax before MySQL 8.0.22', async () => {
27
+ const { connection, query } = createConnection({
28
+ version: '5.7.44',
29
+ replicaStatuses: [{ Channel_Name: '' }]
30
+ });
31
+
32
+ await expect(checkSourceConfiguration(connection)).resolves.toContain(
33
+ 'Connecting PowerSync to a MySQL replica is not supported. Please connect PowerSync directly to the primary server.'
34
+ );
35
+ expect(query).toHaveBeenCalledWith('SHOW SLAVE STATUS', []);
36
+ expect(query).not.toHaveBeenCalledWith('SHOW REPLICA STATUS', []);
37
+ });
38
+
39
+ function createConnection(options: { version: string; replicaStatuses: Record<string, unknown>[] }) {
40
+ return createMockMySQLConnection(async (sql) => {
41
+ switch (sql.trim()) {
42
+ case 'SELECT VERSION() as version':
43
+ return [[{ version: options.version }], []];
44
+ case 'SHOW REPLICA STATUS':
45
+ case 'SHOW SLAVE STATUS':
46
+ return [options.replicaStatuses, []];
47
+ case "SHOW VARIABLES LIKE 'binlog_format';":
48
+ return [[{ Value: 'ROW' }], []];
49
+ case "SHOW GLOBAL VARIABLES LIKE 'binlog_row_image';":
50
+ return [[{ Value: 'FULL' }], []];
51
+ default:
52
+ if (sql.includes('@@GLOBAL.gtid_mode AS gtid_mode')) {
53
+ return [
54
+ [
55
+ {
56
+ gtid_mode: 'ON',
57
+ log_bin: 1,
58
+ server_id: 1,
59
+ binlog_file: '/var/lib/mysql/binlog',
60
+ binlog_index_file: '/var/lib/mysql/binlog.index'
61
+ }
62
+ ],
63
+ []
64
+ ];
65
+ }
66
+ throw new Error(`Unexpected query: ${sql}`);
67
+ }
68
+ });
69
+ }
70
+ });
@@ -1,4 +1,5 @@
1
- import { isVersionAtLeast } from '@module/utils/mysql-utils.js';
1
+ import * as types from '@module/types/types.js';
2
+ import { createPool, isVersionAtLeast, TCP_KEEPALIVE_INITIAL_DELAY } from '@module/utils/mysql-utils.js';
2
3
  import { describe, expect, test } from 'vitest';
3
4
 
4
5
  describe('MySQL Utility Tests', () => {
@@ -14,4 +15,21 @@ describe('MySQL Utility Tests', () => {
14
15
  expect(isVersionAtLeast(olderVersion, '8.0')).toBeFalsy();
15
16
  expect(isVersionAtLeast(improperSemver, '5.7')).toBeTruthy();
16
17
  });
18
+
19
+ test('Pool connections are configured with a TCP keepalive initial delay', async () => {
20
+ // mysql2 enables keepalive by default, but without an initial delay the OS default of
21
+ // 7200 seconds applies, which is too late for common 3600 second firewall idle timeouts.
22
+ const config = types.normalizeConnectionConfig({
23
+ type: 'mysql',
24
+ uri: 'mysql://root:password@localhost:3306/mydatabase'
25
+ });
26
+ // The pool is lazy, so no connection is made here.
27
+ const pool = createPool(config);
28
+ const { connectionConfig } = (pool as unknown as { config: { connectionConfig: Record<string, unknown> } }).config;
29
+
30
+ expect(connectionConfig.enableKeepAlive).toBe(true);
31
+ expect(connectionConfig.keepAliveInitialDelay).toBe(TCP_KEEPALIVE_INITIAL_DELAY);
32
+
33
+ await pool.promise().end();
34
+ });
17
35
  });
@@ -0,0 +1,188 @@
1
+ import { ReplicatedGTID } from '@module/common/ReplicatedGTID.js';
2
+ import {
3
+ getLatestActiveGtid,
4
+ isGtidPositionStillAvailable,
5
+ readExecutedGtid
6
+ } from '@module/common/read-executed-gtid.js';
7
+ import { describe, expect, test } from 'vitest';
8
+ import { createMockMySQLConnection } from './util.js';
9
+
10
+ describe('read-executed-gtid', () => {
11
+ const ACTIVE_SERVER_UUID = 'a7d0ff7b-0c0e-11f0-8b38-566fbaa00004';
12
+ const STALE_SERVER_UUID = '314306f3-ff7b-11ef-a0e0-566fbaa00002';
13
+
14
+ describe('getLatestActiveGtid', () => {
15
+ test('returns the highest transaction id for the active server', async () => {
16
+ const gtid = await getLatestActiveGtid(
17
+ [`${STALE_SERVER_UUID}:1-1000`, `\n${ACTIVE_SERVER_UUID}:1-5:9:12-17`],
18
+ ACTIVE_SERVER_UUID
19
+ );
20
+
21
+ expect(gtid).toEqual(`${ACTIVE_SERVER_UUID}:17`);
22
+ });
23
+
24
+ test('supports a bare transaction id', async () => {
25
+ const gtid = await getLatestActiveGtid([`${ACTIVE_SERVER_UUID}:42`], ACTIVE_SERVER_UUID);
26
+
27
+ expect(gtid).toEqual(`${ACTIVE_SERVER_UUID}:42`);
28
+ });
29
+
30
+ test('returns the active server ZERO GTID when it is absent from the GTID sets', async () => {
31
+ await expect(getLatestActiveGtid([`${STALE_SERVER_UUID}:1-1000`], ACTIVE_SERVER_UUID)).resolves.toEqual(
32
+ `${ACTIVE_SERVER_UUID}:0`
33
+ );
34
+ });
35
+ });
36
+
37
+ describe('readExecutedGtid', () => {
38
+ test('reads binary log status on MySQL 8.4 and selects the active server GTID', async () => {
39
+ const { connection, query } = createConnection({
40
+ version: '8.4.0',
41
+ executedGtidSet: `${STALE_SERVER_UUID}:1-1000,\n${ACTIVE_SERVER_UUID}:1-5:11-18`
42
+ });
43
+
44
+ const gtid = await readExecutedGtid(connection);
45
+
46
+ expect(gtid.raw).toEqual(`${ACTIVE_SERVER_UUID}:18`);
47
+ expect(gtid.position).toEqual({ filename: 'binlog.000042', offset: 1234 });
48
+ expect(query).toHaveBeenCalledWith('SHOW BINARY LOG STATUS', []);
49
+ expect(query).not.toHaveBeenCalledWith('SHOW MASTER STATUS', []);
50
+ });
51
+
52
+ test('reads master status on MySQL versions before 8.4', async () => {
53
+ const { connection, query } = createConnection({
54
+ version: '8.0.40',
55
+ executedGtidSet: `${ACTIVE_SERVER_UUID}:1-17`
56
+ });
57
+
58
+ const gtid = await readExecutedGtid(connection);
59
+
60
+ expect(gtid.raw).toEqual(`${ACTIVE_SERVER_UUID}:17`);
61
+ expect(query).toHaveBeenCalledWith('SHOW MASTER STATUS', []);
62
+ expect(query).not.toHaveBeenCalledWith('SHOW BINARY LOG STATUS', []);
63
+ });
64
+
65
+ test('returns the active server ZERO GTID when no transactions have executed', async () => {
66
+ const { connection } = createConnection({
67
+ version: '8.4.0',
68
+ executedGtidSet: ' \n\t '
69
+ });
70
+
71
+ const gtid = await readExecutedGtid(connection);
72
+
73
+ expect(gtid.raw).toEqual(`${ACTIVE_SERVER_UUID}:0`);
74
+ expect(gtid.position).toEqual({ filename: 'binlog.000042', offset: 1234 });
75
+ expect(gtid.comparable).toEqual(`0000000000000000|${ACTIVE_SERVER_UUID}:0|binlog.000042|1234`);
76
+ });
77
+
78
+ test('uses the active server ZERO GTID at the current position when only historical UUIDs exist', async () => {
79
+ const { connection } = createConnection({
80
+ version: '8.4.0',
81
+ executedGtidSet: `${STALE_SERVER_UUID}:1-1000`
82
+ });
83
+
84
+ const gtid = await readExecutedGtid(connection);
85
+
86
+ expect(gtid.raw).toEqual(`${ACTIVE_SERVER_UUID}:0`);
87
+ expect(gtid.position).toEqual({ filename: 'binlog.000042', offset: 1234 });
88
+ expect(gtid.comparable).toEqual(`0000000000000000|${ACTIVE_SERVER_UUID}:0|binlog.000042|1234`);
89
+ });
90
+ });
91
+
92
+ describe('isGtidPositionStillAvailable', () => {
93
+ const RESUME_GTID = new ReplicatedGTID({
94
+ rawGtid: `${ACTIVE_SERVER_UUID}:17`,
95
+ position: { filename: 'binlog.000042', offset: 1234 }
96
+ });
97
+
98
+ test('returns true when the GTID is executed and its binlog coordinate is available', async () => {
99
+ const { connection, query } = createResumeCheckConnection({
100
+ isExecuted: 1,
101
+ logFiles: [{ Log_name: 'binlog.000042', File_size: 2000 }]
102
+ });
103
+
104
+ await expect(isGtidPositionStillAvailable(connection, RESUME_GTID)).resolves.toBe(true);
105
+ expect(query).toHaveBeenCalledWith('SELECT GTID_SUBSET(?, @@GLOBAL.gtid_executed) AS is_executed', [
106
+ RESUME_GTID.raw
107
+ ]);
108
+ });
109
+
110
+ test('returns false when the GTID is absent after a source rewind', async () => {
111
+ const { connection } = createResumeCheckConnection({
112
+ isExecuted: 0,
113
+ logFiles: [{ Log_name: 'binlog.000042', File_size: 2000 }]
114
+ });
115
+
116
+ await expect(isGtidPositionStillAvailable(connection, RESUME_GTID)).resolves.toBe(false);
117
+ });
118
+
119
+ test('validates the synthetic ZERO GTID using only its binlog coordinate', async () => {
120
+ const zeroGtid = new ReplicatedGTID({
121
+ rawGtid: `${ACTIVE_SERVER_UUID}:0`,
122
+ position: { filename: 'binlog.000042', offset: 1234 }
123
+ });
124
+ const { connection, query } = createResumeCheckConnection({
125
+ isExecuted: 0,
126
+ logFiles: [{ Log_name: 'binlog.000042', File_size: 2000 }]
127
+ });
128
+
129
+ await expect(isGtidPositionStillAvailable(connection, zeroGtid)).resolves.toBe(true);
130
+ expect(query).not.toHaveBeenCalledWith('SELECT GTID_SUBSET(?, @@GLOBAL.gtid_executed) AS is_executed', [
131
+ zeroGtid.raw
132
+ ]);
133
+ });
134
+
135
+ test.each([
136
+ [[], 'the binlog file is absent'],
137
+ [[{ Log_name: 'binlog.000042', File_size: 1000 }], 'the stored offset is past the end of the binlog']
138
+ ])('returns false when %s (%s)', async (logFiles) => {
139
+ const { connection, query } = createResumeCheckConnection({ isExecuted: 1, logFiles });
140
+
141
+ await expect(isGtidPositionStillAvailable(connection, RESUME_GTID)).resolves.toBe(false);
142
+ expect(query).not.toHaveBeenCalledWith('SELECT GTID_SUBSET(?, @@GLOBAL.gtid_executed) AS is_executed', [
143
+ RESUME_GTID.raw
144
+ ]);
145
+ });
146
+ });
147
+
148
+ function createConnection(options: { version: string; executedGtidSet: string }) {
149
+ return createMockMySQLConnection(async (sql) => {
150
+ switch (sql) {
151
+ case 'SELECT VERSION() as version':
152
+ return [[{ version: options.version }], []];
153
+ case 'SHOW BINARY LOG STATUS':
154
+ case 'SHOW MASTER STATUS':
155
+ return [
156
+ [
157
+ {
158
+ File: 'binlog.000042',
159
+ Position: '1234',
160
+ Executed_Gtid_Set: options.executedGtidSet
161
+ }
162
+ ],
163
+ []
164
+ ];
165
+ case 'SELECT @@server_uuid AS server_uuid':
166
+ return [[{ server_uuid: ACTIVE_SERVER_UUID }], []];
167
+ default:
168
+ throw new Error(`Unexpected query: ${sql}`);
169
+ }
170
+ });
171
+ }
172
+
173
+ function createResumeCheckConnection(options: {
174
+ isExecuted: number;
175
+ logFiles: { Log_name: string; File_size: number }[];
176
+ }) {
177
+ return createMockMySQLConnection(async (sql) => {
178
+ switch (sql) {
179
+ case 'SHOW BINARY LOGS;':
180
+ return [options.logFiles, []];
181
+ case 'SELECT GTID_SUBSET(?, @@GLOBAL.gtid_executed) AS is_executed':
182
+ return [[{ is_executed: options.isExecuted }], []];
183
+ default:
184
+ throw new Error(`Unexpected query: ${sql}`);
185
+ }
186
+ });
187
+ }
188
+ });