@powersync/service-module-mysql 0.7.4 → 0.8.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dev/docker/mysql/init-scripts/my.cnf +1 -3
  3. package/dist/api/MySQLRouteAPIAdapter.js +11 -3
  4. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -1
  5. package/dist/common/ReplicatedGTID.js +4 -0
  6. package/dist/common/ReplicatedGTID.js.map +1 -1
  7. package/dist/common/common-index.d.ts +1 -2
  8. package/dist/common/common-index.js +1 -2
  9. package/dist/common/common-index.js.map +1 -1
  10. package/dist/common/mysql-to-sqlite.js +4 -0
  11. package/dist/common/mysql-to-sqlite.js.map +1 -1
  12. package/dist/common/schema-utils.d.ts +20 -0
  13. package/dist/common/{get-replication-columns.js → schema-utils.js} +73 -30
  14. package/dist/common/schema-utils.js.map +1 -0
  15. package/dist/replication/BinLogStream.d.ts +9 -6
  16. package/dist/replication/BinLogStream.js +99 -70
  17. package/dist/replication/BinLogStream.js.map +1 -1
  18. package/dist/replication/zongji/BinLogListener.d.ts +52 -5
  19. package/dist/replication/zongji/BinLogListener.js +302 -85
  20. package/dist/replication/zongji/BinLogListener.js.map +1 -1
  21. package/dist/replication/zongji/zongji-utils.d.ts +2 -1
  22. package/dist/replication/zongji/zongji-utils.js +3 -0
  23. package/dist/replication/zongji/zongji-utils.js.map +1 -1
  24. package/dist/types/node-sql-parser-extended-types.d.ts +31 -0
  25. package/dist/types/node-sql-parser-extended-types.js +2 -0
  26. package/dist/types/node-sql-parser-extended-types.js.map +1 -0
  27. package/dist/utils/mysql-utils.d.ts +4 -2
  28. package/dist/utils/mysql-utils.js +15 -3
  29. package/dist/utils/mysql-utils.js.map +1 -1
  30. package/dist/utils/parser-utils.d.ts +16 -0
  31. package/dist/utils/parser-utils.js +58 -0
  32. package/dist/utils/parser-utils.js.map +1 -0
  33. package/package.json +9 -8
  34. package/src/api/MySQLRouteAPIAdapter.ts +11 -3
  35. package/src/common/ReplicatedGTID.ts +6 -1
  36. package/src/common/common-index.ts +1 -2
  37. package/src/common/mysql-to-sqlite.ts +3 -0
  38. package/src/common/{get-replication-columns.ts → schema-utils.ts} +96 -37
  39. package/src/replication/BinLogStream.ts +119 -91
  40. package/src/replication/zongji/BinLogListener.ts +370 -93
  41. package/src/replication/zongji/zongji-utils.ts +6 -1
  42. package/src/types/node-sql-parser-extended-types.ts +25 -0
  43. package/src/utils/mysql-utils.ts +19 -4
  44. package/src/utils/parser-utils.ts +73 -0
  45. package/test/src/BinLogListener.test.ts +415 -32
  46. package/test/src/BinLogStream.test.ts +128 -52
  47. package/test/src/BinlogStreamUtils.ts +12 -2
  48. package/test/src/parser-utils.test.ts +24 -0
  49. package/test/src/schema-changes.test.ts +663 -0
  50. package/test/src/util.ts +6 -0
  51. package/tsconfig.tsbuildinfo +1 -1
  52. package/dist/common/get-replication-columns.d.ts +0 -12
  53. package/dist/common/get-replication-columns.js.map +0 -1
  54. package/dist/common/get-tables-from-pattern.d.ts +0 -7
  55. package/dist/common/get-tables-from-pattern.js +0 -28
  56. package/dist/common/get-tables-from-pattern.js.map +0 -1
  57. package/src/common/get-tables-from-pattern.ts +0 -44
@@ -4,7 +4,8 @@ import { ReplicationMetric } from '@powersync/service-types';
4
4
  import { v4 as uuid } from 'uuid';
5
5
  import { describe, expect, test } from 'vitest';
6
6
  import { BinlogStreamTestContext } from './BinlogStreamUtils.js';
7
- import { describeWithStorage } from './util.js';
7
+ import { createTestDb, describeWithStorage } from './util.js';
8
+ import { qualifiedMySQLTable } from '@module/utils/mysql-utils.js';
8
9
 
9
10
  const BASIC_SYNC_RULES = `
10
11
  bucket_definitions:
@@ -13,7 +14,7 @@ bucket_definitions:
13
14
  - SELECT id, description FROM "test_data"
14
15
  `;
15
16
 
16
- describe('BigLog stream', () => {
17
+ describe('BinLogStream tests', () => {
17
18
  describeWithStorage({ timeout: 20_000 }, defineBinlogStreamTests);
18
19
  });
19
20
 
@@ -34,7 +35,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
34
35
  const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
35
36
  const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
36
37
 
37
- context.startStreaming();
38
+ await context.startStreaming();
38
39
  const testId = uuid();
39
40
  await connectionManager.query(
40
41
  `INSERT INTO test_data(id, description, num) VALUES('${testId}', 'test1', 1152921504606846976)`
@@ -48,7 +49,59 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
48
49
  expect(endTxCount - startTxCount).toEqual(1);
49
50
  });
50
51
 
51
- test('replicating case sensitive table', async () => {
52
+ test('Replicate multi schema sync rules', async () => {
53
+ await using context = await BinlogStreamTestContext.open(factory);
54
+ const { connectionManager } = context;
55
+ await context.updateSyncRules(`
56
+ bucket_definitions:
57
+ default_schema_test_data:
58
+ data:
59
+ - SELECT id, description, num FROM "${connectionManager.databaseName}"."test_data"
60
+ multi_schema_test_data:
61
+ data:
62
+ - SELECT id, description, num FROM "multi_schema"."test_data"
63
+ `);
64
+
65
+ await createTestDb(connectionManager, 'multi_schema');
66
+
67
+ await connectionManager.query(`CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description TEXT, num BIGINT)`);
68
+ const testTable = qualifiedMySQLTable('test_data', 'multi_schema');
69
+ await connectionManager.query(
70
+ `CREATE TABLE IF NOT EXISTS ${testTable} (id CHAR(36) PRIMARY KEY,description TEXT);`
71
+ );
72
+ await context.replicateSnapshot();
73
+
74
+ const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
75
+ const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
76
+
77
+ await context.startStreaming();
78
+
79
+ const testId = uuid();
80
+ await connectionManager.query(
81
+ `INSERT INTO test_data(id, description, num) VALUES('${testId}', 'test1', 1152921504606846976)`
82
+ );
83
+
84
+ const testId2 = uuid();
85
+ await connectionManager.query(`INSERT INTO ${testTable}(id, description) VALUES('${testId2}', 'test2')`);
86
+
87
+ const default_data = await context.getBucketData('default_schema_test_data[]');
88
+ expect(default_data).toMatchObject([
89
+ putOp('test_data', { id: testId, description: 'test1', num: 1152921504606846976n })
90
+ ]);
91
+
92
+ const multi_schema_data = await context.getBucketData('multi_schema_test_data[]');
93
+ expect(multi_schema_data).toMatchObject([putOp('test_data', { id: testId2, description: 'test2' })]);
94
+
95
+ const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
96
+ const endTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
97
+ expect(endRowCount - startRowCount).toEqual(2);
98
+ expect(endTxCount - startTxCount).toEqual(2);
99
+ });
100
+
101
+ test('Replicate case sensitive table', async () => {
102
+ // MySQL inherits the case sensitivity of the underlying OS filesystem.
103
+ // So Unix-based systems will have case-sensitive tables, but Windows won't.
104
+ // https://dev.mysql.com/doc/refman/8.4/en/identifier-case-sensitivity.html
52
105
  await using context = await BinlogStreamTestContext.open(factory);
53
106
  const { connectionManager } = context;
54
107
  await context.updateSyncRules(`
@@ -65,7 +118,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
65
118
  const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
66
119
  const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
67
120
 
68
- context.startStreaming();
121
+ await context.startStreaming();
69
122
 
70
123
  const testId = uuid();
71
124
  await connectionManager.query(`INSERT INTO test_DATA(id, description) VALUES('${testId}','test1')`);
@@ -79,50 +132,73 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
79
132
  expect(endTxCount - startTxCount).toEqual(1);
80
133
  });
81
134
 
82
- // TODO: Not supported yet
83
- // test('replicating TRUNCATE', async () => {
84
- // await using context = await BinlogStreamTestContext.create(factory);
85
- // const { connectionManager } = context;
86
- // const syncRuleContent = `
87
- // bucket_definitions:
88
- // global:
89
- // data:
90
- // - SELECT id, description FROM "test_data"
91
- // by_test_data:
92
- // parameters: SELECT id FROM test_data WHERE id = token_parameters.user_id
93
- // data: []
94
- // `;
95
- // await context.updateSyncRules(syncRuleContent);
96
- // await connectionManager.query(`DROP TABLE IF EXISTS test_data`);
97
- // await connectionManager.query(
98
- // `CREATE TABLE test_data(id uuid primary key default uuid_generate_v4(), description text)`
99
- // );
100
-
101
- // await context.replicateSnapshot();
102
- // context.startStreaming();
103
-
104
- // const [{ test_id }] = pgwireRows(
105
- // await connectionManager.query(`INSERT INTO test_data(description) VALUES('test1') returning id as test_id`)
106
- // );
107
- // await connectionManager.query(`TRUNCATE test_data`);
108
-
109
- // const data = await context.getBucketData('global[]');
110
-
111
- // expect(data).toMatchObject([
112
- // putOp('test_data', { id: test_id, description: 'test1' }),
113
- // removeOp('test_data', test_id)
114
- // ]);
115
- // });
116
-
117
- test('replicating changing primary key', async () => {
135
+ test('Replicate matched wild card tables in sync rules', async () => {
118
136
  await using context = await BinlogStreamTestContext.open(factory);
119
137
  const { connectionManager } = context;
138
+ await context.updateSyncRules(`
139
+ bucket_definitions:
140
+ global:
141
+ data:
142
+ - SELECT id, description FROM "test_data_%"`);
143
+
144
+ await connectionManager.query(`CREATE TABLE test_data_1 (id CHAR(36) PRIMARY KEY, description TEXT)`);
145
+ await connectionManager.query(`CREATE TABLE test_data_2 (id CHAR(36) PRIMARY KEY, description TEXT)`);
146
+
147
+ const testId11 = uuid();
148
+ await connectionManager.query(`INSERT INTO test_data_1(id, description) VALUES('${testId11}','test11')`);
149
+
150
+ const testId21 = uuid();
151
+ await connectionManager.query(`INSERT INTO test_data_2(id, description) VALUES('${testId21}','test21')`);
152
+
153
+ await context.replicateSnapshot();
154
+ await context.startStreaming();
155
+
156
+ const testId12 = uuid();
157
+ await connectionManager.query(`INSERT INTO test_data_1(id, description) VALUES('${testId12}', 'test12')`);
158
+
159
+ const testId22 = uuid();
160
+ await connectionManager.query(`INSERT INTO test_data_2(id, description) VALUES('${testId22}', 'test22')`);
161
+ const data = await context.getBucketData('global[]');
162
+
163
+ expect(data).toMatchObject([
164
+ putOp('test_data_1', { id: testId11, description: 'test11' }),
165
+ putOp('test_data_2', { id: testId21, description: 'test21' }),
166
+ putOp('test_data_1', { id: testId12, description: 'test12' }),
167
+ putOp('test_data_2', { id: testId22, description: 'test22' })
168
+ ]);
169
+ });
170
+
171
+ test('Handle table TRUNCATE events', async () => {
172
+ await using context = await BinlogStreamTestContext.open(factory);
173
+ await context.updateSyncRules(BASIC_SYNC_RULES);
174
+
175
+ const { connectionManager } = context;
176
+ await connectionManager.query(`CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description text)`);
177
+
178
+ await context.replicateSnapshot();
179
+ await context.startStreaming();
180
+
181
+ const testId = uuid();
182
+ await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('${testId}','test1')`);
183
+ await connectionManager.query(`TRUNCATE TABLE test_data`);
184
+
185
+ const data = await context.getBucketData('global[]');
186
+
187
+ expect(data).toMatchObject([
188
+ putOp('test_data', { id: testId, description: 'test1' }),
189
+ removeOp('test_data', testId)
190
+ ]);
191
+ });
192
+
193
+ test('Handle changes in a replicated table primary key', async () => {
194
+ await using context = await BinlogStreamTestContext.open(factory);
120
195
  await context.updateSyncRules(BASIC_SYNC_RULES);
121
196
 
197
+ const { connectionManager } = context;
122
198
  await connectionManager.query(`CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description text)`);
123
199
 
124
200
  await context.replicateSnapshot();
125
- context.startStreaming();
201
+ await context.startStreaming();
126
202
 
127
203
  const testId1 = uuid();
128
204
  await connectionManager.query(`INSERT INTO test_data(id, description) VALUES('${testId1}','test1')`);
@@ -154,7 +230,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
154
230
  ]);
155
231
  });
156
232
 
157
- test('initial sync', async () => {
233
+ test('Initial snapshot sync', async () => {
158
234
  await using context = await BinlogStreamTestContext.open(factory);
159
235
  const { connectionManager } = context;
160
236
  await context.updateSyncRules(BASIC_SYNC_RULES);
@@ -167,7 +243,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
167
243
  const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
168
244
 
169
245
  await context.replicateSnapshot();
170
- context.startStreaming();
246
+ await context.startStreaming();
171
247
 
172
248
  const endRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
173
249
  const data = await context.getBucketData('global[]');
@@ -175,7 +251,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
175
251
  expect(endRowCount - startRowCount).toEqual(1);
176
252
  });
177
253
 
178
- test('snapshot with date values', async () => {
254
+ test('Snapshot with date values', async () => {
179
255
  await using context = await BinlogStreamTestContext.open(factory);
180
256
  const { connectionManager } = context;
181
257
  await context.updateSyncRules(`
@@ -195,7 +271,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
195
271
  `);
196
272
 
197
273
  await context.replicateSnapshot();
198
- context.startStreaming();
274
+ await context.startStreaming();
199
275
 
200
276
  const data = await context.getBucketData('global[]');
201
277
  expect(data).toMatchObject([
@@ -209,7 +285,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
209
285
  ]);
210
286
  });
211
287
 
212
- test('replication with date values', async () => {
288
+ test('Replication with date values', async () => {
213
289
  await using context = await BinlogStreamTestContext.open(factory);
214
290
  const { connectionManager } = context;
215
291
  await context.updateSyncRules(`
@@ -228,7 +304,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
228
304
  const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
229
305
  const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
230
306
 
231
- context.startStreaming();
307
+ await context.startStreaming();
232
308
 
233
309
  const testId = uuid();
234
310
  await connectionManager.query(`
@@ -259,7 +335,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
259
335
  expect(endTxCount - startTxCount).toEqual(2);
260
336
  });
261
337
 
262
- test('table not in sync rules', async () => {
338
+ test('Replication for tables not in the sync rules are ignored', async () => {
263
339
  await using context = await BinlogStreamTestContext.open(factory);
264
340
  const { connectionManager } = context;
265
341
  await context.updateSyncRules(BASIC_SYNC_RULES);
@@ -271,7 +347,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
271
347
  const startRowCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.ROWS_REPLICATED)) ?? 0;
272
348
  const startTxCount = (await METRICS_HELPER.getMetricValueForTests(ReplicationMetric.TRANSACTIONS_REPLICATED)) ?? 0;
273
349
 
274
- context.startStreaming();
350
+ await context.startStreaming();
275
351
 
276
352
  await connectionManager.query(`INSERT INTO test_donotsync(id, description) VALUES('${uuid()}','test1')`);
277
353
  const data = await context.getBucketData('global[]');
@@ -300,7 +376,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
300
376
  await connectionManager.query(`CREATE TABLE test_data (id CHAR(36) PRIMARY KEY, description TEXT, num BIGINT)`);
301
377
 
302
378
  await context.replicateSnapshot();
303
- context.startStreaming();
379
+ await context.startStreaming();
304
380
  await connectionManager.query(
305
381
  `INSERT INTO test_data(id, description, num) VALUES('${testId1}', 'test1', 1152921504606846976)`
306
382
  );
@@ -315,7 +391,7 @@ function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
315
391
  await context.loadActiveSyncRules();
316
392
  // Does not actually do a snapshot again - just does the required intialization.
317
393
  await context.replicateSnapshot();
318
- context.startStreaming();
394
+ await context.startStreaming();
319
395
  await connectionManager.query(`INSERT INTO test_data(id, description, num) VALUES('${testId2}', 'test2', 0)`);
320
396
  const data = await context.getBucketData('global[]');
321
397
 
@@ -16,6 +16,7 @@ import {
16
16
  import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
17
17
  import mysqlPromise from 'mysql2/promise';
18
18
  import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
19
+ import timers from 'timers/promises';
19
20
 
20
21
  /**
21
22
  * Tests operating on the binlog stream need to configure the stream and manage asynchronous
@@ -112,15 +113,24 @@ export class BinlogStreamTestContext {
112
113
 
113
114
  async replicateSnapshot() {
114
115
  await this.binlogStream.initReplication();
115
- await this.storage!.autoActivate();
116
116
  this.replicationDone = true;
117
117
  }
118
118
 
119
- startStreaming() {
119
+ async startStreaming() {
120
120
  if (!this.replicationDone) {
121
121
  throw new Error('Call replicateSnapshot() before startStreaming()');
122
122
  }
123
123
  this.streamPromise = this.binlogStream.streamChanges();
124
+
125
+ // Wait for the replication to start before returning.
126
+ // This avoids a bunch of unpredictable race conditions that appear in testing
127
+ return new Promise<void>(async (resolve) => {
128
+ while (this.binlogStream.isStartingReplication) {
129
+ await timers.setTimeout(50);
130
+ }
131
+
132
+ resolve();
133
+ });
124
134
  }
125
135
 
126
136
  async getCheckpoint(options?: { timeout?: number }): Promise<InternalOpId> {
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { matchedSchemaChangeQuery } from '@module/utils/parser-utils.js';
3
+
4
+ describe('MySQL Parser Util Tests', () => {
5
+ test('matchedSchemaChangeQuery function', () => {
6
+ const matcher = (tableName: string) => tableName === 'users';
7
+
8
+ // DDL matches and table name matches
9
+ expect(matchedSchemaChangeQuery('ALTER TABLE users ADD COLUMN name VARCHAR(255)', [matcher])).toBeTruthy();
10
+ expect(matchedSchemaChangeQuery('DROP TABLE users', [matcher])).toBeTruthy();
11
+ expect(matchedSchemaChangeQuery('TRUNCATE TABLE users', [matcher])).toBeTruthy();
12
+ expect(matchedSchemaChangeQuery('RENAME TABLE new_users TO users', [matcher])).toBeTruthy();
13
+
14
+ // Can handle backticks in table names
15
+ expect(
16
+ matchedSchemaChangeQuery('ALTER TABLE `clientSchema`.`users` ADD COLUMN name VARCHAR(255)', [matcher])
17
+ ).toBeTruthy();
18
+
19
+ // DDL matches, but table name does not match
20
+ expect(matchedSchemaChangeQuery('DROP TABLE clientSchema.clients', [matcher])).toBeFalsy();
21
+ // No DDL match
22
+ expect(matchedSchemaChangeQuery('SELECT * FROM users', [matcher])).toBeFalsy();
23
+ });
24
+ });