@powersync/service-module-mysql 0.15.0 → 0.16.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.
@@ -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,6 +82,13 @@ 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;
@@ -84,12 +102,15 @@ export async function createBinlogListener(params: CreateBinlogListenerParams):
84
102
  startGTID = await getFromGTID(connectionManager);
85
103
  }
86
104
 
105
+ const activeServerUuid = await getActiveServerUuid(connectionManager);
106
+
87
107
  return new BinLogListener({
88
108
  connectionManager: connectionManager,
89
109
  eventHandler: eventHandler,
90
110
  startGTID: startGTID!,
91
111
  sourceTables: sourceTables,
92
- serverId: createRandomServerId(1)
112
+ serverId: createRandomServerId(1),
113
+ activeServerUuid: activeServerUuid
93
114
  });
94
115
  }
95
116
 
@@ -100,6 +121,7 @@ export class TestBinLogEventHandler implements BinLogEventHandler {
100
121
  commitCount = 0;
101
122
  schemaChanges: SchemaChange[] = [];
102
123
  lastKeepAlive: string | undefined;
124
+ lastCommitLsn: string | undefined;
103
125
 
104
126
  unpause: ((value: void | PromiseLike<void>) => void) | undefined;
105
127
  private pausedPromise: Promise<void> | undefined;
@@ -127,6 +149,7 @@ export class TestBinLogEventHandler implements BinLogEventHandler {
127
149
 
128
150
  async onCommit(lsn: string) {
129
151
  this.commitCount++;
152
+ this.lastCommitLsn = lsn;
130
153
  }
131
154
 
132
155
  async onSchemaChange(change: SchemaChange) {