@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
@@ -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
+ });
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) {
package/tsconfig.json CHANGED
@@ -3,9 +3,6 @@
3
3
  "compilerOptions": {
4
4
  "rootDir": "src",
5
5
  "outDir": "dist",
6
- "esModuleInterop": true,
7
- "skipLibCheck": true,
8
- "sourceMap": true,
9
6
  "typeRoots": ["./node_modules/@types", "./src/replication/zongji.d.ts"]
10
7
  },
11
8
  "include": ["src"],