@powersync/service-module-mysql 0.0.0-dev-20260602073133 → 0.0.0-dev-20260909133214

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 (51) hide show
  1. package/CHANGELOG.md +134 -4
  2. package/dist/api/MySQLRouteAPIAdapter.d.ts +3 -3
  3. package/dist/api/MySQLRouteAPIAdapter.js +6 -5
  4. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/ReplicatedGTID.d.ts +16 -9
  6. package/dist/common/ReplicatedGTID.js +49 -25
  7. package/dist/common/ReplicatedGTID.js.map +1 -1
  8. package/dist/common/check-source-configuration.js +11 -0
  9. package/dist/common/check-source-configuration.js.map +1 -1
  10. package/dist/common/read-executed-gtid.d.ts +11 -1
  11. package/dist/common/read-executed-gtid.js +67 -5
  12. package/dist/common/read-executed-gtid.js.map +1 -1
  13. package/dist/common/schema-utils.js +4 -0
  14. package/dist/common/schema-utils.js.map +1 -1
  15. package/dist/replication/BinLogReplicationJob.d.ts +1 -1
  16. package/dist/replication/BinLogReplicationJob.js +6 -4
  17. package/dist/replication/BinLogReplicationJob.js.map +1 -1
  18. package/dist/replication/BinLogReplicator.js +1 -1
  19. package/dist/replication/BinLogReplicator.js.map +1 -1
  20. package/dist/replication/BinLogStream.d.ts +8 -0
  21. package/dist/replication/BinLogStream.js +62 -22
  22. package/dist/replication/BinLogStream.js.map +1 -1
  23. package/dist/replication/MySQLConnectionManager.js +6 -0
  24. package/dist/replication/MySQLConnectionManager.js.map +1 -1
  25. package/dist/replication/zongji/BinLogListener.d.ts +41 -2
  26. package/dist/replication/zongji/BinLogListener.js +104 -30
  27. package/dist/replication/zongji/BinLogListener.js.map +1 -1
  28. package/dist/utils/mysql-utils.d.ts +6 -0
  29. package/dist/utils/mysql-utils.js +10 -0
  30. package/dist/utils/mysql-utils.js.map +1 -1
  31. package/package.json +12 -22
  32. package/src/api/MySQLRouteAPIAdapter.ts +10 -9
  33. package/src/common/ReplicatedGTID.ts +65 -32
  34. package/src/common/check-source-configuration.ts +15 -0
  35. package/src/common/read-executed-gtid.ts +76 -6
  36. package/src/common/schema-utils.ts +8 -0
  37. package/src/replication/BinLogReplicationJob.ts +6 -4
  38. package/src/replication/BinLogReplicator.ts +1 -1
  39. package/src/replication/BinLogStream.ts +67 -22
  40. package/src/replication/MySQLConnectionManager.ts +6 -0
  41. package/src/replication/zongji/BinLogListener.ts +131 -30
  42. package/src/utils/mysql-utils.ts +11 -0
  43. package/test/src/BinLogListener.test.ts +140 -1
  44. package/test/src/BinlogStreamUtils.ts +23 -23
  45. package/test/src/ReplicatedGTID.test.ts +138 -0
  46. package/test/src/check-source-configuration.test.ts +70 -0
  47. package/test/src/mysql-utils.test.ts +19 -1
  48. package/test/src/read-executed-gtid.test.ts +188 -0
  49. package/test/src/util.ts +27 -2
  50. package/tsconfig.json +0 -3
  51. package/tsconfig.tsbuildinfo +1 -1
@@ -12,6 +12,13 @@ export type RetriedQueryOptions = {
12
12
  retries?: number;
13
13
  };
14
14
 
15
+ /**
16
+ * TCP keepalive initial delay in milliseconds for connections to the MySQL server.
17
+ * Keepalive prevents long-lived idle connections from being silently dropped by stateful
18
+ * firewalls, which commonly time out idle flows after an hour.
19
+ */
20
+ export const TCP_KEEPALIVE_INITIAL_DELAY = 40_000;
21
+
15
22
  /**
16
23
  * Retry a simple query - up to 2 attempts total.
17
24
  */
@@ -54,6 +61,10 @@ export function createPool(config: types.NormalizedMySQLConnectionConfig, option
54
61
  timezone: 'Z', // Ensure no auto timezone manipulation of the dates occur
55
62
  jsonStrings: true, // Return JSON columns as strings
56
63
  dateStrings: true, // We parse and format them ourselves
64
+ // mysql2 enables TCP keepalive by default, but without an initial delay the OS default of
65
+ // 7200 seconds applies, which is too late for common 3600 second firewall idle timeouts.
66
+ enableKeepAlive: true,
67
+ keepAliveInitialDelay: TCP_KEEPALIVE_INITIAL_DELAY,
57
68
  // Apply URL connection parameters (explicit options override these via spread below)
58
69
  ...(params.connectTimeout != null ? { connectTimeout: params.connectTimeout } : {}),
59
70
  ...(params.connectionLimit != null ? { connectionLimit: params.connectionLimit } : {}),
@@ -1,6 +1,12 @@
1
1
  import { MySQLConnectionManager } from '@module/replication/MySQLConnectionManager.js';
2
2
  import { BinLogListener, SchemaChange, SchemaChangeType } from '@module/replication/zongji/BinLogListener.js';
3
- import { getMySQLVersion, qualifiedMySQLTable, satisfiesVersion } from '@module/utils/mysql-utils.js';
3
+ import {
4
+ getMySQLVersion,
5
+ qualifiedMySQLTable,
6
+ satisfiesVersion,
7
+ TCP_KEEPALIVE_INITIAL_DELAY
8
+ } from '@module/utils/mysql-utils.js';
9
+ import { MySQLConnection } from '@powersync/mysql-zongji';
4
10
  import { TablePattern } from '@powersync/service-sync-rules';
5
11
  import crypto from 'crypto';
6
12
  import { v4 as uuid } from 'uuid';
@@ -13,6 +19,11 @@ import {
13
19
  TestBinLogEventHandler
14
20
  } from './util.js';
15
21
 
22
+ // The zongji type definitions do not expose the connection config.
23
+ type ConnectionWithConfig = MySQLConnection & {
24
+ config: { enableKeepAlive?: boolean; keepAliveInitialDelay?: number };
25
+ };
26
+
16
27
  describe('BinlogListener tests', { timeout: 60_000 }, () => {
17
28
  const MAX_QUEUE_CAPACITY_MB = 1;
18
29
  const BINLOG_LISTENER_CONNECTION_OPTIONS = {
@@ -59,6 +70,116 @@ describe('BinlogListener tests', { timeout: 60_000 }, () => {
59
70
 
60
71
  expect(stopSpy).toHaveBeenCalled();
61
72
  expect(queueStopSpy).toHaveBeenCalled();
73
+ // Zongji destroys its control connection when stopping.
74
+ expect(binLogListener.zongji.ctrlConnection.state).toBe('disconnected');
75
+ });
76
+
77
+ test('TCP keepalive is enabled on the binlog and control connections', async () => {
78
+ // Without keepalive, the control connection can idle for hours and be silently dropped by
79
+ // stateful firewalls. The next metadata query then blocks until the kernel gives up on TCP
80
+ // retransmissions, freezing the whole binlog pipeline for ~15 minutes.
81
+ const { connection } = binLogListener.zongji as unknown as { connection: ConnectionWithConfig };
82
+ const controlConnection = binLogListener.zongji.ctrlConnection as ConnectionWithConfig;
83
+
84
+ for (const conn of [connection, controlConnection]) {
85
+ expect(conn.config.enableKeepAlive).toBe(true);
86
+ expect(conn.config.keepAliveInitialDelay).toBe(TCP_KEEPALIVE_INITIAL_DELAY);
87
+ }
88
+ });
89
+
90
+ test('Stop completes when the control connection is unresponsive', { timeout: 20_000 }, async () => {
91
+ await binLogListener.start();
92
+
93
+ // Simulate a control connection that was silently dropped by the network: the KILL query
94
+ // issued by zongji.stop() never gets a response.
95
+ vi.spyOn(binLogListener.zongji.ctrlConnection, 'query').mockImplementation(() => {});
96
+
97
+ await binLogListener.stop();
98
+
99
+ expect(binLogListener.zongji.stopped).toBeTruthy();
100
+ });
101
+
102
+ test('Probe on a healthy control connection passes', { timeout: 20_000 }, async () => {
103
+ await binLogListener.start();
104
+
105
+ // No mocking: the real driver must accept the options form of query and answer the probe.
106
+ const controlConnection = binLogListener.zongji.ctrlConnection;
107
+ const realQuery = controlConnection.query.bind(controlConnection);
108
+ const probeError = new Promise((resolve) => {
109
+ vi.spyOn(controlConnection, 'query').mockImplementation(((options: any, callback: any) => {
110
+ realQuery(options, (error: any, results: any, fields: any) => {
111
+ callback(error, results, fields);
112
+ resolve(error);
113
+ });
114
+ }) as any);
115
+ });
116
+
117
+ binLogListener.probeControlConnection();
118
+
119
+ expect(await probeError).toBeNull();
120
+ expect(binLogListener.zongji.stopped).toBeFalsy();
121
+
122
+ await binLogListener.stop();
123
+ });
124
+
125
+ test('Probe detects an unresponsive control connection', { timeout: 20_000 }, async () => {
126
+ binLogListener = await createBinlogListener({
127
+ connectionManager,
128
+ sourceTables: [new TablePattern(connectionManager.databaseName, 'test_DATA')],
129
+ eventHandler,
130
+ ctrlConnectionProbeTimeoutMs: 500
131
+ });
132
+ await binLogListener.start();
133
+
134
+ // The probe query starts executing but never gets a response, like a connection that died
135
+ // without either side being notified: the driver's query timeout fires.
136
+ vi.spyOn(binLogListener.zongji.ctrlConnection, 'query').mockImplementation(((_options: any, callback: any) => {
137
+ const error: any = new Error('Query inactivity timeout');
138
+ error.code = 'PROTOCOL_SEQUENCE_TIMEOUT';
139
+ setTimeout(() => callback(error), 10);
140
+ }) as any);
141
+
142
+ const replication = binLogListener.replicateUntilStopped();
143
+ binLogListener.probeControlConnection();
144
+
145
+ await expect(replication).rejects.toThrow('control connection is unresponsive');
146
+ expect(binLogListener.zongji.stopped).toBeTruthy();
147
+ });
148
+
149
+ test('Probe queued behind a busy control connection does not report it dead', { timeout: 20_000 }, async () => {
150
+ binLogListener = await createBinlogListener({
151
+ connectionManager,
152
+ sourceTables: [new TablePattern(connectionManager.databaseName, 'test_DATA')],
153
+ eventHandler,
154
+ ctrlConnectionProbeTimeoutMs: 500
155
+ });
156
+ await binLogListener.start();
157
+
158
+ // The probe never even starts executing, as if queued behind a long-running metadata query on
159
+ // a healthy connection. The probe must not report the connection dead, and further probes must
160
+ // not pile up behind the pending one.
161
+ const querySpy = vi.spyOn(binLogListener.zongji.ctrlConnection, 'query').mockImplementation((() => {}) as any);
162
+
163
+ binLogListener.probeControlConnection();
164
+ binLogListener.probeControlConnection();
165
+
166
+ expect(querySpy).toHaveBeenCalledTimes(1);
167
+ expect(binLogListener.zongji.stopped).toBeFalsy();
168
+
169
+ querySpy.mockRestore();
170
+ await binLogListener.stop();
171
+ });
172
+
173
+ test('Control connection errors stop the listener', { timeout: 20_000 }, async () => {
174
+ await binLogListener.start();
175
+
176
+ const replication = binLogListener.replicateUntilStopped();
177
+ // The driver emits 'error' when the socket fails while no query is pending. Without the
178
+ // forwarding set up in createBinlogListener, this event has no listener and crashes the process.
179
+ (binLogListener.zongji.ctrlConnection as any).emit('error', new Error('Control connection failure'));
180
+
181
+ await expect(replication).rejects.toThrow('Control connection failure');
182
+ expect(binLogListener.zongji.stopped).toBeTruthy();
62
183
  });
63
184
 
64
185
  test('Zongji listener is stopped when processing queue reaches maximum memory size', async () => {
@@ -111,6 +232,24 @@ describe('BinlogListener tests', { timeout: 60_000 }, () => {
111
232
  expect(eventHandler.lastKeepAlive).toEqual(binLogListener.options.startGTID.comparable);
112
233
  });
113
234
 
235
+ test('Keepalive LSN after a commit is not less than the commit LSN', async () => {
236
+ binLogListener.options.keepAliveInactivitySeconds = 1;
237
+ await binLogListener.start();
238
+
239
+ await insertRows(connectionManager, 1);
240
+ await vi.waitFor(() => expect(eventHandler.commitCount).equals(1), { timeout: 5000 });
241
+ const commitLsn = eventHandler.lastCommitLsn!;
242
+
243
+ // Wait for a heartbeat keepalive that arrives after the commit.
244
+ // A keepalive LSN behind the commit LSN blocks checkpoint creation until the next transaction arrives.
245
+ await vi.waitFor(() => expect(eventHandler.lastKeepAlive && eventHandler.lastKeepAlive >= commitLsn).toBeTruthy(), {
246
+ timeout: 10_000
247
+ });
248
+ await binLogListener.stop();
249
+ // No binlog rotation happens in this test, so the keepalive LSN should exactly match the commit LSN
250
+ expect(eventHandler.lastKeepAlive).toEqual(commitLsn);
251
+ });
252
+
114
253
  test('Schema change event: Rename table', async () => {
115
254
  await binLogListener.start();
116
255
  await connectionManager.query(`ALTER TABLE test_DATA RENAME test_DATA_new`);
@@ -33,7 +33,7 @@ export class BinlogStreamTestContext {
33
33
  private streamPromise?: Promise<void>;
34
34
  public storage?: SyncRulesBucketStorage;
35
35
  private replicationDone = false;
36
- private syncRulesContent?: storage.PersistedSyncRulesContent;
36
+ private syncRulesContent?: storage.PersistedSyncConfigContent;
37
37
 
38
38
  static async open(factory: storage.TestStorageFactory, options?: { doNotClear?: boolean }) {
39
39
  const f = await factory({ doNotClear: options?.doNotClear });
@@ -71,38 +71,38 @@ export class BinlogStreamTestContext {
71
71
  }
72
72
 
73
73
  async updateSyncRules(content: string): Promise<SyncRulesBucketStorage> {
74
- const syncRules = await this.factory.updateSyncRules(
74
+ const replicationStream = await this.factory.updateSyncRules(
75
75
  updateSyncRulesFromYaml(content, { validate: true, storageVersion: LEGACY_STORAGE_VERSION })
76
76
  );
77
- this.syncRulesContent = syncRules;
78
- this.storage = this.factory.getInstance(syncRules);
77
+ this.syncRulesContent = replicationStream.syncConfigContent[0];
78
+ this.storage = this.factory.getInstance(replicationStream);
79
79
  return this.storage!;
80
80
  }
81
81
 
82
82
  async loadNextSyncRules() {
83
- const syncRules = await this.factory.getNextSyncRulesContent();
84
- if (syncRules == null) {
83
+ const syncConfig = await this.factory.getDeployingSyncConfig();
84
+ if (syncConfig == null) {
85
85
  throw new Error(`Next replication stream not available`);
86
86
  }
87
87
 
88
- this.syncRulesContent = syncRules;
89
- this.storage = this.factory.getInstance(syncRules);
88
+ this.syncRulesContent = syncConfig.content;
89
+ this.storage = syncConfig.storage;
90
90
  return this.storage!;
91
91
  }
92
92
 
93
93
  async loadActiveSyncRules() {
94
- const syncRules = await this.factory.getActiveSyncRulesContent();
95
- if (syncRules == null) {
94
+ const syncConfig = await this.factory.getActiveSyncConfig();
95
+ if (syncConfig == null) {
96
96
  throw new Error(`Active replication stream not available`);
97
97
  }
98
98
 
99
- this.syncRulesContent = syncRules;
100
- this.storage = this.factory.getInstance(syncRules);
99
+ this.syncRulesContent = syncConfig.content;
100
+ this.storage = syncConfig.storage;
101
101
  this.replicationDone = true;
102
102
  return this.storage!;
103
103
  }
104
104
 
105
- private getSyncRulesContent(): storage.PersistedSyncRulesContent {
105
+ private getSyncConfigContent(): storage.PersistedSyncConfigContent {
106
106
  if (this.syncRulesContent == null) {
107
107
  throw new Error('Sync config not configured - call updateSyncRules() first');
108
108
  }
@@ -148,7 +148,7 @@ export class BinlogStreamTestContext {
148
148
  });
149
149
  }
150
150
 
151
- async getCheckpoint(options?: { timeout?: number }): Promise<InternalOpId> {
151
+ async getCheckpoint(options?: { timeout?: number }): Promise<ReplicationCheckpoint> {
152
152
  const connection = await this.connectionManager.getConnection();
153
153
  let checkpoint = await Promise.race([
154
154
  getClientCheckpoint(connection, this.factory, { timeout: options?.timeout ?? 60_000 }),
@@ -165,9 +165,9 @@ export class BinlogStreamTestContext {
165
165
 
166
166
  async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
167
167
  const checkpoint = await this.getCheckpoint(options);
168
- const syncRules = this.getSyncRulesContent();
169
- const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncRules, bucket, start));
170
- return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
168
+ const syncConfigContent = this.getSyncConfigContent();
169
+ const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncConfigContent, bucket, start));
170
+ return test_utils.getBatchArray(this.storage!.getBucketDataBatch(checkpoint, map));
171
171
  }
172
172
 
173
173
  async getBucketData(
@@ -179,11 +179,11 @@ export class BinlogStreamTestContext {
179
179
  if (typeof start == 'string') {
180
180
  start = BigInt(start);
181
181
  }
182
- const syncRules = this.getSyncRulesContent();
182
+ const syncConfigContent = this.getSyncConfigContent();
183
183
  const checkpoint = await this.getCheckpoint(options);
184
- const map = [bucketRequest(syncRules, bucket, start)];
184
+ const map = [bucketRequest(syncConfigContent, bucket, start)];
185
185
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
186
- const batches = await test_utils.fromAsync(batch);
186
+ const batches = await test_utils.getBatchArray(batch);
187
187
  return batches[0]?.chunkData.data ?? [];
188
188
  }
189
189
  }
@@ -192,7 +192,7 @@ export async function getClientCheckpoint(
192
192
  connection: mysqlPromise.Connection,
193
193
  storageFactory: BucketStorageFactory,
194
194
  options?: { timeout?: number }
195
- ): Promise<InternalOpId> {
195
+ ): Promise<ReplicationCheckpoint> {
196
196
  const start = Date.now();
197
197
  const gtid = await readExecutedGtid(connection);
198
198
  // This old API needs a persisted checkpoint id.
@@ -203,14 +203,14 @@ export async function getClientCheckpoint(
203
203
 
204
204
  logger.info('Expected Checkpoint: ' + gtid.comparable);
205
205
  while (Date.now() - start < timeout) {
206
- const storage = await storageFactory.getActiveStorage();
206
+ const storage = (await storageFactory.getActiveSyncConfig())?.storage;
207
207
  const cp = await storage?.getCheckpoint();
208
208
  if (cp == null) {
209
209
  throw new Error('No replication stream available');
210
210
  }
211
211
  lastCp = cp;
212
212
  if (cp.lsn && cp.lsn >= gtid.comparable) {
213
- return cp.checkpoint;
213
+ return cp;
214
214
  }
215
215
 
216
216
  await new Promise((resolve) => setTimeout(resolve, 30));
@@ -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
  });