@powersync/service-module-mysql 0.0.0-dev-20241101083236 → 0.0.0-dev-20241111122558

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 (42) hide show
  1. package/CHANGELOG.md +11 -8
  2. package/dev/config/sync_rules.yaml +2 -4
  3. package/dist/api/MySQLRouteAPIAdapter.js +9 -8
  4. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/ReplicatedGTID.js +1 -1
  6. package/dist/common/check-source-configuration.d.ts +0 -1
  7. package/dist/common/check-source-configuration.js +6 -8
  8. package/dist/common/check-source-configuration.js.map +1 -1
  9. package/dist/common/get-replication-columns.js +1 -1
  10. package/dist/common/mysql-to-sqlite.d.ts +17 -1
  11. package/dist/common/mysql-to-sqlite.js +133 -8
  12. package/dist/common/mysql-to-sqlite.js.map +1 -1
  13. package/dist/common/read-executed-gtid.js +4 -10
  14. package/dist/common/read-executed-gtid.js.map +1 -1
  15. package/dist/replication/BinLogStream.js +25 -21
  16. package/dist/replication/BinLogStream.js.map +1 -1
  17. package/dist/replication/MySQLConnectionManager.d.ts +2 -2
  18. package/dist/replication/MySQLConnectionManager.js +1 -1
  19. package/dist/utils/mysql-utils.d.ts +30 -0
  20. package/dist/utils/mysql-utils.js +70 -0
  21. package/dist/utils/mysql-utils.js.map +1 -0
  22. package/package.json +7 -6
  23. package/src/api/MySQLRouteAPIAdapter.ts +11 -9
  24. package/src/common/ReplicatedGTID.ts +1 -1
  25. package/src/common/check-source-configuration.ts +9 -10
  26. package/src/common/get-replication-columns.ts +1 -1
  27. package/src/common/mysql-to-sqlite.ts +147 -8
  28. package/src/common/read-executed-gtid.ts +5 -12
  29. package/src/replication/BinLogStream.ts +29 -21
  30. package/src/replication/MySQLConnectionManager.ts +3 -3
  31. package/src/utils/{mysql_utils.ts → mysql-utils.ts} +36 -5
  32. package/test/src/BinLogStream.test.ts +306 -0
  33. package/test/src/BinlogStreamUtils.ts +157 -0
  34. package/test/src/env.ts +1 -1
  35. package/test/src/mysql-to-sqlite.test.ts +322 -0
  36. package/test/src/mysql-utils.test.ts +17 -0
  37. package/test/src/util.ts +11 -17
  38. package/test/tsconfig.json +1 -1
  39. package/tsconfig.tsbuildinfo +1 -1
  40. package/dist/utils/mysql_utils.d.ts +0 -17
  41. package/dist/utils/mysql_utils.js +0 -43
  42. package/dist/utils/mysql_utils.js.map +0 -1
@@ -0,0 +1,322 @@
1
+ import { SqliteRow } from '@powersync/service-sync-rules';
2
+ import { afterAll, describe, expect, test } from 'vitest';
3
+ import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
4
+ import { eventIsWriteMutation, eventIsXid } from '@module/replication/zongji/zongji-utils.js';
5
+ import * as common from '@module/common/common-index.js';
6
+ import ZongJi, { BinLogEvent } from '@powersync/mysql-zongji';
7
+ import { MySQLConnectionManager } from '@module/replication/MySQLConnectionManager.js';
8
+ import { toColumnDescriptors } from '@module/common/common-index.js';
9
+
10
+ describe('MySQL Data Types', () => {
11
+ const connectionManager = new MySQLConnectionManager(TEST_CONNECTION_OPTIONS, {});
12
+
13
+ afterAll(async () => {
14
+ await connectionManager.end();
15
+ });
16
+
17
+ async function setupTable() {
18
+ const connection = await connectionManager.getConnection();
19
+ await clearTestDb(connection);
20
+ await connection.query(`CREATE TABLE test_data (
21
+ tinyint_col TINYINT,
22
+ smallint_col SMALLINT,
23
+ mediumint_col MEDIUMINT,
24
+ int_col INT,
25
+ integer_col INTEGER,
26
+ bigint_col BIGINT,
27
+ float_col FLOAT,
28
+ double_col DOUBLE,
29
+ decimal_col DECIMAL(10,2),
30
+ numeric_col NUMERIC(10,2),
31
+ bit_col BIT(8),
32
+ boolean_col BOOLEAN,
33
+ serial_col SERIAL,
34
+
35
+ date_col DATE,
36
+ datetime_col DATETIME(3),
37
+ timestamp_col TIMESTAMP(3),
38
+ time_col TIME,
39
+ year_col YEAR,
40
+
41
+ char_col CHAR(10),
42
+ varchar_col VARCHAR(255),
43
+ binary_col BINARY(16),
44
+ varbinary_col VARBINARY(256),
45
+ tinyblob_col TINYBLOB,
46
+ blob_col BLOB,
47
+ mediumblob_col MEDIUMBLOB,
48
+ longblob_col LONGBLOB,
49
+ tinytext_col TINYTEXT,
50
+ text_col TEXT,
51
+ mediumtext_col MEDIUMTEXT,
52
+ longtext_col LONGTEXT,
53
+ enum_col ENUM('value1', 'value2', 'value3'),
54
+ set_col SET('value1', 'value2', 'value3'),
55
+
56
+ json_col JSON,
57
+
58
+ geometry_col GEOMETRY,
59
+ point_col POINT,
60
+ linestring_col LINESTRING,
61
+ polygon_col POLYGON,
62
+ multipoint_col MULTIPOINT,
63
+ multilinestring_col MULTILINESTRING,
64
+ multipolygon_col MULTIPOLYGON,
65
+ geometrycollection_col GEOMETRYCOLLECTION
66
+ )`);
67
+
68
+ connection.release();
69
+ }
70
+
71
+ test('Number types mappings', async () => {
72
+ await setupTable();
73
+ await connectionManager.query(`
74
+ INSERT INTO test_data (
75
+ tinyint_col,
76
+ smallint_col,
77
+ mediumint_col,
78
+ int_col,
79
+ integer_col,
80
+ bigint_col,
81
+ double_col,
82
+ decimal_col,
83
+ numeric_col,
84
+ bit_col,
85
+ boolean_col
86
+ -- serial_col is auto-incremented and can be left out
87
+ ) VALUES (
88
+ 127, -- TINYINT maximum value
89
+ 32767, -- SMALLINT maximum value
90
+ 8388607, -- MEDIUMINT maximum value
91
+ 2147483647, -- INT maximum value
92
+ 2147483647, -- INTEGER maximum value
93
+ 9223372036854775807, -- BIGINT maximum value
94
+ 3.1415926535, -- DOUBLE example
95
+ 12345.67, -- DECIMAL(10,2) example
96
+ 12345.67, -- NUMERIC(10,2) example
97
+ b'10101010', -- BIT(8) example in binary notation
98
+ TRUE -- BOOLEAN value (alias for TINYINT(1))
99
+ -- serial_col is auto-incremented
100
+ )`);
101
+
102
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
103
+ const replicatedRows = await getReplicatedRows();
104
+
105
+ const expectedResult = {
106
+ tinyint_col: 127n,
107
+ smallint_col: 32767n,
108
+ mediumint_col: 8388607n,
109
+ int_col: 2147483647n,
110
+ integer_col: 2147483647n,
111
+ bigint_col: 9223372036854775807n,
112
+ double_col: 3.1415926535,
113
+ decimal_col: 12345.67,
114
+ numeric_col: 12345.67,
115
+ bit_col: new Uint8Array([0b10101010]).valueOf(),
116
+ boolean_col: 1n,
117
+ serial_col: 1n
118
+ };
119
+ expect(databaseRows[0]).toMatchObject(expectedResult);
120
+ expect(replicatedRows[0]).toMatchObject(expectedResult);
121
+ });
122
+
123
+ test('Float type mapping', async () => {
124
+ await setupTable();
125
+ const expectedFloatValue = 3.14;
126
+ await connectionManager.query(`INSERT INTO test_data (float_col) VALUES (${expectedFloatValue})`);
127
+
128
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
129
+ const replicatedRows = await getReplicatedRows();
130
+
131
+ const allowedPrecision = 0.0001;
132
+
133
+ const actualFloatValueDB = databaseRows[0].float_col;
134
+ let difference = Math.abs((actualFloatValueDB as number) - expectedFloatValue);
135
+ expect(difference).toBeLessThan(allowedPrecision);
136
+
137
+ const actualFloatValueReplicated = replicatedRows[0].float_col;
138
+ difference = Math.abs((actualFloatValueReplicated as number) - expectedFloatValue);
139
+ expect(difference).toBeLessThan(allowedPrecision);
140
+ });
141
+
142
+ test('Character types mappings', async () => {
143
+ await setupTable();
144
+ await connectionManager.query(`
145
+ INSERT INTO test_data (
146
+ char_col,
147
+ varchar_col,
148
+ binary_col,
149
+ varbinary_col,
150
+ tinyblob_col,
151
+ blob_col,
152
+ mediumblob_col,
153
+ longblob_col,
154
+ tinytext_col,
155
+ text_col,
156
+ mediumtext_col,
157
+ longtext_col,
158
+ enum_col
159
+ ) VALUES (
160
+ 'CharData', -- CHAR(10) with padding spaces
161
+ 'Variable character data',-- VARCHAR(255)
162
+ 'ShortBin', -- BINARY(16)
163
+ 'VariableBinaryData', -- VARBINARY(256)
164
+ 'TinyBlobData', -- TINYBLOB
165
+ 'BlobData', -- BLOB
166
+ 'MediumBlobData', -- MEDIUMBLOB
167
+ 'LongBlobData', -- LONGBLOB
168
+ 'TinyTextData', -- TINYTEXT
169
+ 'TextData', -- TEXT
170
+ 'MediumTextData', -- MEDIUMTEXT
171
+ 'LongTextData', -- LONGTEXT
172
+ 'value1' -- ENUM('value1', 'value2', 'value3')
173
+ );`);
174
+
175
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
176
+ const replicatedRows = await getReplicatedRows();
177
+ const expectedResult = {
178
+ char_col: 'CharData',
179
+ varchar_col: 'Variable character data',
180
+ binary_col: new Uint8Array([83, 104, 111, 114, 116, 66, 105, 110, 0, 0, 0, 0, 0, 0, 0, 0]), // Pad with 0
181
+ varbinary_col: new Uint8Array([
182
+ 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x42, 0x69, 0x6e, 0x61, 0x72, 0x79, 0x44, 0x61, 0x74, 0x61
183
+ ]),
184
+ tinyblob_col: new Uint8Array([0x54, 0x69, 0x6e, 0x79, 0x42, 0x6c, 0x6f, 0x62, 0x44, 0x61, 0x74, 0x61]),
185
+ blob_col: new Uint8Array([0x42, 0x6c, 0x6f, 0x62, 0x44, 0x61, 0x74, 0x61]),
186
+ mediumblob_col: new Uint8Array([
187
+ 0x4d, 0x65, 0x64, 0x69, 0x75, 0x6d, 0x42, 0x6c, 0x6f, 0x62, 0x44, 0x61, 0x74, 0x61
188
+ ]),
189
+ longblob_col: new Uint8Array([0x4c, 0x6f, 0x6e, 0x67, 0x42, 0x6c, 0x6f, 0x62, 0x44, 0x61, 0x74, 0x61]),
190
+ tinytext_col: 'TinyTextData',
191
+ text_col: 'TextData',
192
+ mediumtext_col: 'MediumTextData',
193
+ longtext_col: 'LongTextData',
194
+ enum_col: 'value1'
195
+ };
196
+
197
+ expect(databaseRows[0]).toMatchObject(expectedResult);
198
+ expect(replicatedRows[0]).toMatchObject(expectedResult);
199
+ });
200
+
201
+ test('Date types mappings', async () => {
202
+ await setupTable();
203
+ await connectionManager.query(`
204
+ INSERT INTO test_data(date_col, datetime_col, timestamp_col, time_col, year_col)
205
+ VALUES('2023-03-06', '2023-03-06 15:47', '2023-03-06 15:47', '15:47:00', '2023');
206
+ `);
207
+
208
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
209
+ const replicatedRows = await getReplicatedRows();
210
+ const expectedResult = {
211
+ date_col: '2023-03-06',
212
+ datetime_col: '2023-03-06T15:47:00.000Z',
213
+ timestamp_col: '2023-03-06T15:47:00.000Z',
214
+ time_col: '15:47:00',
215
+ year_col: 2023
216
+ };
217
+
218
+ expect(databaseRows[0]).toMatchObject(expectedResult);
219
+ expect(replicatedRows[0]).toMatchObject(expectedResult);
220
+ });
221
+
222
+ test('Date types edge cases mappings', async () => {
223
+ await setupTable();
224
+
225
+ await connectionManager.query(`INSERT INTO test_data(timestamp_col) VALUES('1970-01-01 00:00:01')`);
226
+ await connectionManager.query(`INSERT INTO test_data(timestamp_col) VALUES('2038-01-19 03:14:07.499')`);
227
+ await connectionManager.query(`INSERT INTO test_data(datetime_col) VALUES('1000-01-01 00:00:00')`);
228
+ await connectionManager.query(`INSERT INTO test_data(datetime_col) VALUES('9999-12-31 23:59:59.499')`);
229
+
230
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
231
+ const replicatedRows = await getReplicatedRows(4);
232
+ const expectedResults = [
233
+ { timestamp_col: '1970-01-01T00:00:01.000Z' },
234
+ { timestamp_col: '2038-01-19T03:14:07.499Z' },
235
+ { datetime_col: '1000-01-01T00:00:00.000Z' },
236
+ { datetime_col: '9999-12-31T23:59:59.499Z' }
237
+ ];
238
+
239
+ for (let i = 0; i < expectedResults.length; i++) {
240
+ expect(databaseRows[i]).toMatchObject(expectedResults[i]);
241
+ expect(replicatedRows[i]).toMatchObject(expectedResults[i]);
242
+ }
243
+ });
244
+
245
+ test('Json types mappings', async () => {
246
+ await setupTable();
247
+
248
+ const expectedJSON = { name: 'John Doe', age: 30, married: true };
249
+ const expectedSet = ['value1', 'value3'];
250
+
251
+ // For convenience, we map the SET data type to a JSON Array
252
+ await connectionManager.query(
253
+ `INSERT INTO test_data (json_col, set_col) VALUES ('${JSON.stringify(expectedJSON)}', '${expectedSet.join(',')}')`
254
+ );
255
+
256
+ const databaseRows = await getDatabaseRows(connectionManager, 'test_data');
257
+ const replicatedRows = await getReplicatedRows();
258
+
259
+ const actualDBJSONValue = JSON.parse(databaseRows[0].json_col as string);
260
+ const actualReplicatedJSONValue = JSON.parse(replicatedRows[0].json_col as string);
261
+ expect(actualDBJSONValue).toEqual(expectedJSON);
262
+ expect(actualReplicatedJSONValue).toEqual(expectedJSON);
263
+
264
+ const actualDBSetValue = JSON.parse(databaseRows[0].set_col as string);
265
+ const actualReplicatedSetValue = JSON.parse(replicatedRows[0].set_col as string);
266
+ expect(actualDBSetValue).toEqual(expectedSet);
267
+ expect(actualReplicatedSetValue).toEqual(expectedSet);
268
+ });
269
+ });
270
+
271
+ async function getDatabaseRows(connection: MySQLConnectionManager, tableName: string): Promise<SqliteRow[]> {
272
+ const [results, fields] = await connection.query(`SELECT * FROM ${tableName}`);
273
+ const columns = toColumnDescriptors(fields);
274
+ return results.map((row) => common.toSQLiteRow(row, columns));
275
+ }
276
+
277
+ /**
278
+ * Return all the inserts from the first transaction in the binlog stream.
279
+ */
280
+ async function getReplicatedRows(expectedTransactionsCount?: number): Promise<SqliteRow[]> {
281
+ let transformed: SqliteRow[] = [];
282
+ const zongji = new ZongJi({
283
+ host: TEST_CONNECTION_OPTIONS.hostname,
284
+ user: TEST_CONNECTION_OPTIONS.username,
285
+ password: TEST_CONNECTION_OPTIONS.password,
286
+ timeZone: 'Z' // Ensure no auto timezone manipulation of the dates occur
287
+ });
288
+
289
+ const completionPromise = new Promise<SqliteRow[]>((resolve, reject) => {
290
+ zongji.on('binlog', (evt: BinLogEvent) => {
291
+ try {
292
+ if (eventIsWriteMutation(evt)) {
293
+ const tableMapEntry = evt.tableMap[evt.tableId];
294
+ const columns = toColumnDescriptors(tableMapEntry);
295
+ const records = evt.rows.map((row: Record<string, any>) => common.toSQLiteRow(row, columns));
296
+ transformed.push(...records);
297
+ } else if (eventIsXid(evt)) {
298
+ if (expectedTransactionsCount !== undefined) {
299
+ expectedTransactionsCount--;
300
+ if (expectedTransactionsCount == 0) {
301
+ zongji.stop();
302
+ resolve(transformed);
303
+ }
304
+ } else {
305
+ zongji.stop();
306
+ resolve(transformed);
307
+ }
308
+ }
309
+ } catch (e) {
310
+ reject(e);
311
+ }
312
+ });
313
+ });
314
+
315
+ zongji.start({
316
+ includeEvents: ['tablemap', 'writerows', 'xid'],
317
+ filename: 'mysql-bin.000001',
318
+ position: 0
319
+ });
320
+
321
+ return completionPromise;
322
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { isVersionAtLeast } from '@module/utils/mysql-utils.js';
3
+
4
+ describe('MySQL Utility Tests', () => {
5
+ test('Minimum version checking ', () => {
6
+ const newerVersion = '8.4.0';
7
+ const olderVersion = '5.7';
8
+ const sameVersion = '8.0';
9
+ // Improperly formatted semantic versions should be handled gracefully if possible
10
+ const improperSemver = '5.7.42-0ubuntu0.18.04.1-log';
11
+
12
+ expect(isVersionAtLeast(newerVersion, '8.0')).toBeTruthy();
13
+ expect(isVersionAtLeast(sameVersion, '8.0')).toBeTruthy();
14
+ expect(isVersionAtLeast(olderVersion, '8.0')).toBeFalsy();
15
+ expect(isVersionAtLeast(improperSemver, '5.7')).toBeTruthy();
16
+ });
17
+ });
package/test/src/util.ts CHANGED
@@ -3,9 +3,14 @@ import { BucketStorageFactory, Metrics, MongoBucketStorage } from '@powersync/se
3
3
  import { env } from './env.js';
4
4
  import mysqlPromise from 'mysql2/promise';
5
5
  import { connectMongo } from '@core-tests/util.js';
6
- import { getMySQLVersion } from '@module/common/check-source-configuration.js';
7
- import { gte } from 'semver';
8
- import { RowDataPacket } from 'mysql2';
6
+ import { getMySQLVersion, isVersionAtLeast } from '@module/utils/mysql-utils.js';
7
+
8
+ export const TEST_URI = env.MYSQL_TEST_URI;
9
+
10
+ export const TEST_CONNECTION_OPTIONS = types.normalizeConnectionConfig({
11
+ type: 'mysql',
12
+ uri: TEST_URI
13
+ });
9
14
 
10
15
  // The metrics need to be initialized before they can be used
11
16
  await Metrics.initialise({
@@ -15,13 +20,6 @@ await Metrics.initialise({
15
20
  });
16
21
  Metrics.getInstance().resetCounters();
17
22
 
18
- export const TEST_URI = env.MYSQL_TEST_URI;
19
-
20
- export const TEST_CONNECTION_OPTIONS = types.normalizeConnectionConfig({
21
- type: 'mysql',
22
- uri: TEST_URI
23
- });
24
-
25
23
  export type StorageFactory = () => Promise<BucketStorageFactory>;
26
24
 
27
25
  export const INITIALIZED_MONGO_STORAGE_FACTORY: StorageFactory = async () => {
@@ -37,19 +35,15 @@ export const INITIALIZED_MONGO_STORAGE_FACTORY: StorageFactory = async () => {
37
35
  return new MongoBucketStorage(db, { slot_name_prefix: 'test_' });
38
36
  };
39
37
 
40
- export async function clearAndRecreateTestDb(connection: mysqlPromise.Connection) {
38
+ export async function clearTestDb(connection: mysqlPromise.Connection) {
41
39
  const version = await getMySQLVersion(connection);
42
- if (gte(version, '8.4.0')) {
40
+ if (isVersionAtLeast(version, '8.4.0')) {
43
41
  await connection.query('RESET BINARY LOGS AND GTIDS');
44
42
  } else {
45
43
  await connection.query('RESET MASTER');
46
44
  }
47
45
 
48
- // await connection.query(`DROP DATABASE IF EXISTS ${TEST_CONNECTION_OPTIONS.database}`);
49
- //
50
- // await connection.query(`CREATE DATABASE IF NOT EXISTS ${TEST_CONNECTION_OPTIONS.database}`);
51
-
52
- const [result] = await connection.query<RowDataPacket[]>(
46
+ const [result] = await connection.query<mysqlPromise.RowDataPacket[]>(
53
47
  `SELECT TABLE_NAME FROM information_schema.tables
54
48
  WHERE TABLE_SCHEMA = '${TEST_CONNECTION_OPTIONS.database}'`
55
49
  );
@@ -13,7 +13,7 @@
13
13
  "@core-tests/*": ["../../../packages/service-core/test/src/*"]
14
14
  }
15
15
  },
16
- "include": ["src"],
16
+ "include": ["src", "../src/replication/zongji/zongji.d.ts"],
17
17
  "references": [
18
18
  {
19
19
  "path": "../"