@powersync/service-module-mysql 0.0.0-dev-20241015210820

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 (96) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +67 -0
  3. package/README.md +3 -0
  4. package/dev/.env.template +2 -0
  5. package/dev/README.md +9 -0
  6. package/dev/config/sync_rules.yaml +12 -0
  7. package/dev/docker/mysql/docker-compose.yaml +17 -0
  8. package/dev/docker/mysql/init-scripts/my.cnf +9 -0
  9. package/dev/docker/mysql/init-scripts/mysql.sql +38 -0
  10. package/dist/api/MySQLRouteAPIAdapter.d.ts +24 -0
  11. package/dist/api/MySQLRouteAPIAdapter.js +311 -0
  12. package/dist/api/MySQLRouteAPIAdapter.js.map +1 -0
  13. package/dist/common/ReplicatedGTID.d.ts +59 -0
  14. package/dist/common/ReplicatedGTID.js +110 -0
  15. package/dist/common/ReplicatedGTID.js.map +1 -0
  16. package/dist/common/check-source-configuration.d.ts +3 -0
  17. package/dist/common/check-source-configuration.js +46 -0
  18. package/dist/common/check-source-configuration.js.map +1 -0
  19. package/dist/common/common-index.d.ts +6 -0
  20. package/dist/common/common-index.js +7 -0
  21. package/dist/common/common-index.js.map +1 -0
  22. package/dist/common/get-replication-columns.d.ts +12 -0
  23. package/dist/common/get-replication-columns.js +103 -0
  24. package/dist/common/get-replication-columns.js.map +1 -0
  25. package/dist/common/get-tables-from-pattern.d.ts +7 -0
  26. package/dist/common/get-tables-from-pattern.js +28 -0
  27. package/dist/common/get-tables-from-pattern.js.map +1 -0
  28. package/dist/common/mysql-to-sqlite.d.ts +4 -0
  29. package/dist/common/mysql-to-sqlite.js +56 -0
  30. package/dist/common/mysql-to-sqlite.js.map +1 -0
  31. package/dist/common/read-executed-gtid.d.ts +6 -0
  32. package/dist/common/read-executed-gtid.js +40 -0
  33. package/dist/common/read-executed-gtid.js.map +1 -0
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.js +4 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/module/MySQLModule.d.ts +13 -0
  38. package/dist/module/MySQLModule.js +46 -0
  39. package/dist/module/MySQLModule.js.map +1 -0
  40. package/dist/replication/BinLogReplicationJob.d.ts +14 -0
  41. package/dist/replication/BinLogReplicationJob.js +88 -0
  42. package/dist/replication/BinLogReplicationJob.js.map +1 -0
  43. package/dist/replication/BinLogReplicator.d.ts +13 -0
  44. package/dist/replication/BinLogReplicator.js +25 -0
  45. package/dist/replication/BinLogReplicator.js.map +1 -0
  46. package/dist/replication/BinLogStream.d.ts +43 -0
  47. package/dist/replication/BinLogStream.js +421 -0
  48. package/dist/replication/BinLogStream.js.map +1 -0
  49. package/dist/replication/MySQLConnectionManager.d.ts +43 -0
  50. package/dist/replication/MySQLConnectionManager.js +81 -0
  51. package/dist/replication/MySQLConnectionManager.js.map +1 -0
  52. package/dist/replication/MySQLConnectionManagerFactory.d.ts +10 -0
  53. package/dist/replication/MySQLConnectionManagerFactory.js +21 -0
  54. package/dist/replication/MySQLConnectionManagerFactory.js.map +1 -0
  55. package/dist/replication/MySQLErrorRateLimiter.d.ts +10 -0
  56. package/dist/replication/MySQLErrorRateLimiter.js +43 -0
  57. package/dist/replication/MySQLErrorRateLimiter.js.map +1 -0
  58. package/dist/replication/zongji/zongji-utils.d.ts +7 -0
  59. package/dist/replication/zongji/zongji-utils.js +19 -0
  60. package/dist/replication/zongji/zongji-utils.js.map +1 -0
  61. package/dist/types/types.d.ts +50 -0
  62. package/dist/types/types.js +61 -0
  63. package/dist/types/types.js.map +1 -0
  64. package/dist/utils/mysql_utils.d.ts +14 -0
  65. package/dist/utils/mysql_utils.js +38 -0
  66. package/dist/utils/mysql_utils.js.map +1 -0
  67. package/package.json +51 -0
  68. package/src/api/MySQLRouteAPIAdapter.ts +357 -0
  69. package/src/common/ReplicatedGTID.ts +158 -0
  70. package/src/common/check-source-configuration.ts +59 -0
  71. package/src/common/common-index.ts +6 -0
  72. package/src/common/get-replication-columns.ts +124 -0
  73. package/src/common/get-tables-from-pattern.ts +44 -0
  74. package/src/common/mysql-to-sqlite.ts +59 -0
  75. package/src/common/read-executed-gtid.ts +43 -0
  76. package/src/index.ts +5 -0
  77. package/src/module/MySQLModule.ts +53 -0
  78. package/src/replication/BinLogReplicationJob.ts +97 -0
  79. package/src/replication/BinLogReplicator.ts +35 -0
  80. package/src/replication/BinLogStream.ts +547 -0
  81. package/src/replication/MySQLConnectionManager.ts +104 -0
  82. package/src/replication/MySQLConnectionManagerFactory.ts +28 -0
  83. package/src/replication/MySQLErrorRateLimiter.ts +44 -0
  84. package/src/replication/zongji/zongji-utils.ts +32 -0
  85. package/src/replication/zongji/zongji.d.ts +98 -0
  86. package/src/types/types.ts +102 -0
  87. package/src/utils/mysql_utils.ts +47 -0
  88. package/test/src/binlog_stream.test.ts +288 -0
  89. package/test/src/binlog_stream_utils.ts +152 -0
  90. package/test/src/env.ts +7 -0
  91. package/test/src/setup.ts +7 -0
  92. package/test/src/util.ts +62 -0
  93. package/test/tsconfig.json +28 -0
  94. package/tsconfig.json +26 -0
  95. package/tsconfig.tsbuildinfo +1 -0
  96. package/vitest.config.ts +15 -0
@@ -0,0 +1,357 @@
1
+ import { api, ParseSyncRulesOptions, storage } from '@powersync/service-core';
2
+
3
+ import * as sync_rules from '@powersync/service-sync-rules';
4
+ import * as service_types from '@powersync/service-types';
5
+ import mysql from 'mysql2/promise';
6
+ import * as common from '../common/common-index.js';
7
+ import * as mysql_utils from '../utils/mysql_utils.js';
8
+ import * as types from '../types/types.js';
9
+ import { toExpressionTypeFromMySQLType } from '../common/common-index.js';
10
+
11
+ type SchemaResult = {
12
+ schema_name: string;
13
+ table_name: string;
14
+ columns: Array<{ data_type: string; column_name: string }>;
15
+ };
16
+
17
+ export class MySQLRouteAPIAdapter implements api.RouteAPI {
18
+ protected pool: mysql.Pool;
19
+
20
+ constructor(protected config: types.ResolvedConnectionConfig) {
21
+ this.pool = mysql_utils.createPool(config).promise();
22
+ }
23
+
24
+ async shutdown(): Promise<void> {
25
+ return this.pool.end();
26
+ }
27
+
28
+ async getSourceConfig(): Promise<service_types.configFile.ResolvedDataSourceConfig> {
29
+ return this.config;
30
+ }
31
+
32
+ getParseSyncRulesOptions(): ParseSyncRulesOptions {
33
+ return {
34
+ // In MySQL Schema and Database are the same thing. There is no default database
35
+ defaultSchema: this.config.database
36
+ };
37
+ }
38
+
39
+ async getConnectionStatus(): Promise<service_types.ConnectionStatusV2> {
40
+ const base = {
41
+ id: this.config.id,
42
+ uri: `mysql://${this.config.hostname}:${this.config.port}/${this.config.database}`
43
+ };
44
+ try {
45
+ await this.retriedQuery({
46
+ query: `SELECT 'PowerSync connection test'`
47
+ });
48
+ } catch (e) {
49
+ return {
50
+ ...base,
51
+ connected: false,
52
+ errors: [{ level: 'fatal', message: `${e.code} - message: ${e.message}` }]
53
+ };
54
+ }
55
+ const connection = await this.pool.getConnection();
56
+ try {
57
+ const errors = await common.checkSourceConfiguration(connection);
58
+ if (errors.length) {
59
+ return {
60
+ ...base,
61
+ connected: true,
62
+ errors: errors.map((e) => ({ level: 'fatal', message: e }))
63
+ };
64
+ }
65
+ } catch (e) {
66
+ return {
67
+ ...base,
68
+ connected: true,
69
+ errors: [{ level: 'fatal', message: e.message }]
70
+ };
71
+ } finally {
72
+ connection.release();
73
+ }
74
+ return {
75
+ ...base,
76
+ connected: true,
77
+ errors: []
78
+ };
79
+ }
80
+
81
+ async executeQuery(query: string, params: any[]): Promise<service_types.internal_routes.ExecuteSqlResponse> {
82
+ if (!this.config.debug_api) {
83
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
84
+ results: {
85
+ columns: [],
86
+ rows: []
87
+ },
88
+ success: false,
89
+ error: 'SQL querying is not enabled'
90
+ });
91
+ }
92
+ try {
93
+ const [results, fields] = await this.pool.query<mysql.RowDataPacket[]>(query, params);
94
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
95
+ success: true,
96
+ results: {
97
+ columns: fields.map((c) => c.name),
98
+ rows: results.map((row) => {
99
+ /**
100
+ * Row will be in the format:
101
+ * @rows: [ { test: 2 } ]
102
+ */
103
+ return fields.map((c) => {
104
+ const value = row[c.name];
105
+ const sqlValue = sync_rules.toSyncRulesValue(value);
106
+ if (typeof sqlValue == 'bigint') {
107
+ return Number(value);
108
+ } else if (value instanceof Date) {
109
+ return value.toISOString();
110
+ } else if (sync_rules.isJsonValue(sqlValue)) {
111
+ return sqlValue;
112
+ } else {
113
+ return null;
114
+ }
115
+ });
116
+ })
117
+ }
118
+ });
119
+ } catch (e) {
120
+ return service_types.internal_routes.ExecuteSqlResponse.encode({
121
+ results: {
122
+ columns: [],
123
+ rows: []
124
+ },
125
+ success: false,
126
+ error: e.message
127
+ });
128
+ }
129
+ }
130
+
131
+ async getDebugTablesInfo(
132
+ tablePatterns: sync_rules.TablePattern[],
133
+ sqlSyncRules: sync_rules.SqlSyncRules
134
+ ): Promise<api.PatternResult[]> {
135
+ let result: api.PatternResult[] = [];
136
+
137
+ for (let tablePattern of tablePatterns) {
138
+ const schema = tablePattern.schema;
139
+ let patternResult: api.PatternResult = {
140
+ schema: schema,
141
+ pattern: tablePattern.tablePattern,
142
+ wildcard: tablePattern.isWildcard
143
+ };
144
+ result.push(patternResult);
145
+
146
+ if (tablePattern.isWildcard) {
147
+ patternResult.tables = [];
148
+ const prefix = tablePattern.tablePrefix;
149
+
150
+ const [results] = await this.pool.query<mysql.RowDataPacket[]>(
151
+ `SELECT
152
+ TABLE_NAME AS table_name
153
+ FROM
154
+ INFORMATION_SCHEMA.TABLES
155
+ WHERE
156
+ TABLE_SCHEMA = ?
157
+ AND TABLE_NAME LIKE ?`,
158
+ [schema, tablePattern.tablePattern]
159
+ );
160
+
161
+ for (let row of results) {
162
+ const name = row.table_name as string;
163
+
164
+ if (!name.startsWith(prefix)) {
165
+ continue;
166
+ }
167
+
168
+ const details = await this.getDebugTableInfo(tablePattern, name, sqlSyncRules);
169
+ patternResult.tables.push(details);
170
+ }
171
+ } else {
172
+ const [results] = await this.pool.query<mysql.RowDataPacket[]>(
173
+ `SELECT
174
+ TABLE_NAME AS table_name
175
+ FROM
176
+ INFORMATION_SCHEMA.TABLES
177
+ WHERE
178
+ TABLE_SCHEMA = ?
179
+ AND TABLE_NAME = ?`,
180
+ [tablePattern.schema, tablePattern.tablePattern]
181
+ );
182
+
183
+ if (results.length == 0) {
184
+ // Table not found
185
+ patternResult.table = await this.getDebugTableInfo(tablePattern, tablePattern.name, sqlSyncRules);
186
+ } else {
187
+ const row = results[0];
188
+ patternResult.table = await this.getDebugTableInfo(tablePattern, row.table_name, sqlSyncRules);
189
+ }
190
+ }
191
+ }
192
+
193
+ return result;
194
+ }
195
+
196
+ protected async getDebugTableInfo(
197
+ tablePattern: sync_rules.TablePattern,
198
+ tableName: string,
199
+ syncRules: sync_rules.SqlSyncRules
200
+ ): Promise<service_types.TableInfo> {
201
+ const { schema } = tablePattern;
202
+
203
+ let idColumnsResult: common.ReplicationIdentityColumnsResult | null = null;
204
+ let idColumnsError: service_types.ReplicationError | null = null;
205
+ let connection: mysql.PoolConnection | null = null;
206
+ try {
207
+ connection = await this.pool.getConnection();
208
+ idColumnsResult = await common.getReplicationIdentityColumns({
209
+ connection: connection,
210
+ schema,
211
+ table_name: tableName
212
+ });
213
+ } catch (ex) {
214
+ idColumnsError = { level: 'fatal', message: ex.message };
215
+ } finally {
216
+ connection?.release();
217
+ }
218
+
219
+ const idColumns = idColumnsResult?.columns ?? [];
220
+ const sourceTable = new storage.SourceTable(0, this.config.tag, tableName, schema, tableName, idColumns, true);
221
+ const syncData = syncRules.tableSyncsData(sourceTable);
222
+ const syncParameters = syncRules.tableSyncsParameters(sourceTable);
223
+
224
+ if (idColumns.length == 0 && idColumnsError == null) {
225
+ let message = `No replication id found for ${sourceTable.qualifiedName}. Replica identity: ${idColumnsResult?.identity}.`;
226
+ if (idColumnsResult?.identity == 'default') {
227
+ message += ' Configure a primary key on the table.';
228
+ }
229
+ idColumnsError = { level: 'fatal', message };
230
+ }
231
+
232
+ let selectError: service_types.ReplicationError | null = null;
233
+ try {
234
+ await this.retriedQuery({
235
+ query: `SELECT * FROM ${sourceTable.table} LIMIT 1`
236
+ });
237
+ } catch (e) {
238
+ selectError = { level: 'fatal', message: e.message };
239
+ }
240
+
241
+ return {
242
+ schema: schema,
243
+ name: tableName,
244
+ pattern: tablePattern.isWildcard ? tablePattern.tablePattern : undefined,
245
+ replication_id: idColumns.map((c) => c.name),
246
+ data_queries: syncData,
247
+ parameter_queries: syncParameters,
248
+ errors: [idColumnsError, selectError].filter((error) => error != null) as service_types.ReplicationError[]
249
+ };
250
+ }
251
+
252
+ async getReplicationLag(options: api.ReplicationLagOptions): Promise<number | undefined> {
253
+ const { bucketStorage } = options;
254
+ const lastCheckpoint = await bucketStorage.getCheckpoint();
255
+
256
+ const current = lastCheckpoint.lsn
257
+ ? common.ReplicatedGTID.fromSerialized(lastCheckpoint.lsn)
258
+ : common.ReplicatedGTID.ZERO;
259
+
260
+ const connection = await this.pool.getConnection();
261
+ const head = await common.readExecutedGtid(connection);
262
+ const lag = await current.distanceTo(connection, head);
263
+ connection.release();
264
+ if (lag == null) {
265
+ throw new Error(`Could not determine replication lag`);
266
+ }
267
+
268
+ return lag;
269
+ }
270
+
271
+ async getReplicationHead(): Promise<string> {
272
+ const connection = await this.pool.getConnection();
273
+ const result = await common.readExecutedGtid(connection);
274
+ connection.release();
275
+ return result.comparable;
276
+ }
277
+
278
+ async getConnectionSchema(): Promise<service_types.DatabaseSchema[]> {
279
+ const [results] = await this.retriedQuery({
280
+ query: `
281
+ SELECT
282
+ tbl.schema_name,
283
+ tbl.table_name,
284
+ tbl.quoted_name,
285
+ JSON_ARRAYAGG(JSON_OBJECT('column_name', a.column_name, 'data_type', a.data_type)) AS columns
286
+ FROM
287
+ (
288
+ SELECT
289
+ TABLE_SCHEMA AS schema_name,
290
+ TABLE_NAME AS table_name,
291
+ CONCAT('\`', TABLE_SCHEMA, '\`.\`', TABLE_NAME, '\`') AS quoted_name
292
+ FROM
293
+ INFORMATION_SCHEMA.TABLES
294
+ WHERE
295
+ TABLE_TYPE = 'BASE TABLE'
296
+ AND TABLE_SCHEMA NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys')
297
+ ) AS tbl
298
+ LEFT JOIN
299
+ (
300
+ SELECT
301
+ TABLE_SCHEMA AS schema_name,
302
+ TABLE_NAME AS table_name,
303
+ COLUMN_NAME AS column_name,
304
+ COLUMN_TYPE AS data_type
305
+ FROM
306
+ INFORMATION_SCHEMA.COLUMNS
307
+ ) AS a
308
+ ON
309
+ tbl.schema_name = a.schema_name
310
+ AND tbl.table_name = a.table_name
311
+ GROUP BY
312
+ tbl.schema_name, tbl.table_name, tbl.quoted_name;
313
+ `
314
+ });
315
+
316
+ /**
317
+ * Reduces the SQL results into a Record of {@link DatabaseSchema}
318
+ * then returns the values as an array.
319
+ */
320
+
321
+ return Object.values(
322
+ (results as SchemaResult[]).reduce((hash: Record<string, service_types.DatabaseSchema>, result) => {
323
+ const schema =
324
+ hash[result.schema_name] ||
325
+ (hash[result.schema_name] = {
326
+ name: result.schema_name,
327
+ tables: []
328
+ });
329
+
330
+ schema.tables.push({
331
+ name: result.table_name,
332
+ columns: result.columns.map((column) => ({
333
+ name: column.column_name,
334
+ type: column.data_type,
335
+ sqlite_type: toExpressionTypeFromMySQLType(column.data_type).typeFlags,
336
+ internal_type: column.data_type,
337
+ pg_type: column.data_type
338
+ }))
339
+ });
340
+
341
+ return hash;
342
+ }, {})
343
+ );
344
+ }
345
+
346
+ protected async retriedQuery(options: { query: string; params?: any[] }) {
347
+ const connection = await this.pool.getConnection();
348
+
349
+ return mysql_utils
350
+ .retriedQuery({
351
+ connection: connection,
352
+ query: options.query,
353
+ params: options.params
354
+ })
355
+ .finally(() => connection.release());
356
+ }
357
+ }
@@ -0,0 +1,158 @@
1
+ import mysql from 'mysql2/promise';
2
+ import * as uuid from 'uuid';
3
+ import * as mysql_utils from '../utils/mysql_utils.js';
4
+
5
+ export type BinLogPosition = {
6
+ filename: string;
7
+ offset: number;
8
+ };
9
+
10
+ export type ReplicatedGTIDSpecification = {
11
+ raw_gtid: string;
12
+ /**
13
+ * The (end) position in a BinLog file where this transaction has been replicated in.
14
+ */
15
+ position: BinLogPosition;
16
+ };
17
+
18
+ export type BinLogGTIDFormat = {
19
+ server_id: Buffer;
20
+ transaction_range: number;
21
+ };
22
+
23
+ export type BinLogGTIDEvent = {
24
+ raw_gtid: BinLogGTIDFormat;
25
+ position: BinLogPosition;
26
+ };
27
+
28
+ /**
29
+ * A wrapper around the MySQL GTID value.
30
+ * This adds and tracks additional metadata such as the BinLog filename
31
+ * and position where this GTID could be located.
32
+ */
33
+ export class ReplicatedGTID {
34
+ static fromSerialized(comparable: string): ReplicatedGTID {
35
+ return new ReplicatedGTID(ReplicatedGTID.deserialize(comparable));
36
+ }
37
+
38
+ private static deserialize(comparable: string): ReplicatedGTIDSpecification {
39
+ const components = comparable.split('|');
40
+ if (components.length < 3) {
41
+ throw new Error(`Invalid serialized GTID: ${comparable}`);
42
+ }
43
+
44
+ return {
45
+ raw_gtid: components[1],
46
+ position: {
47
+ filename: components[2],
48
+ offset: parseInt(components[3])
49
+ } satisfies BinLogPosition
50
+ };
51
+ }
52
+
53
+ static fromBinLogEvent(event: BinLogGTIDEvent) {
54
+ const { raw_gtid, position } = event;
55
+ const stringGTID = `${uuid.stringify(raw_gtid.server_id)}:${raw_gtid.transaction_range}`;
56
+ return new ReplicatedGTID({
57
+ raw_gtid: stringGTID,
58
+ position
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Special case for the zero GTID which means no transactions have been executed.
64
+ */
65
+ static ZERO = new ReplicatedGTID({ raw_gtid: '0:0', position: { filename: '', offset: 0 } });
66
+
67
+ constructor(protected options: ReplicatedGTIDSpecification) {}
68
+
69
+ /**
70
+ * Get the BinLog position of this replicated GTID event
71
+ */
72
+ get position() {
73
+ return this.options.position;
74
+ }
75
+
76
+ /**
77
+ * Get the raw Global Transaction ID. This of the format `server_id:transaction_ranges`
78
+ */
79
+ get raw() {
80
+ return this.options.raw_gtid;
81
+ }
82
+
83
+ get serverId() {
84
+ return this.options.raw_gtid.split(':')[0];
85
+ }
86
+
87
+ /**
88
+ * Transforms a GTID into a comparable string format, ensuring lexicographical
89
+ * order aligns with the GTID's relative age. This assumes that all GTIDs
90
+ * have the same server ID.
91
+ *
92
+ * @returns A comparable string in the format
93
+ * `padded_end_transaction|raw_gtid|binlog_filename|binlog_position`
94
+ */
95
+ get comparable() {
96
+ const { raw, position } = this;
97
+ const [, transactionRanges] = this.raw.split(':');
98
+
99
+ let maxTransactionId = 0;
100
+
101
+ for (const range of transactionRanges.split(',')) {
102
+ const [start, end] = range.split('-');
103
+ maxTransactionId = Math.max(maxTransactionId, parseInt(start, 10), parseInt(end || start, 10));
104
+ }
105
+
106
+ const paddedTransactionId = maxTransactionId.toString().padStart(16, '0');
107
+ return [paddedTransactionId, raw, position.filename, position.offset].join('|');
108
+ }
109
+
110
+ toString() {
111
+ return this.comparable;
112
+ }
113
+
114
+ /**
115
+ * Calculates the distance in bytes from this GTID to the provided argument.
116
+ */
117
+ async distanceTo(connection: mysql.Connection, to: ReplicatedGTID): Promise<number | null> {
118
+ const [logFiles] = await mysql_utils.retriedQuery({
119
+ connection,
120
+ query: `SHOW BINARY LOGS;`
121
+ });
122
+
123
+ // Default to the first file for the start to handle the zero GTID case.
124
+ const startFileIndex = Math.max(
125
+ logFiles.findIndex((f) => f['Log_name'] == this.position.filename),
126
+ 0
127
+ );
128
+ const startFileEntry = logFiles[startFileIndex];
129
+
130
+ if (!startFileEntry) {
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Fall back to the next position for comparison if the replicated position is not present
136
+ */
137
+ const endPosition = to.position;
138
+
139
+ // Default to the past the last file to cater for the HEAD case
140
+ const testEndFileIndex = logFiles.findIndex((f) => f['Log_name'] == endPosition?.filename);
141
+ // If the endPosition is not defined and found. Fallback to the last file as the end
142
+ const endFileIndex = testEndFileIndex < 0 && !endPosition ? logFiles.length : logFiles.length - 1;
143
+
144
+ const endFileEntry = logFiles[endFileIndex];
145
+
146
+ if (!endFileEntry) {
147
+ return null;
148
+ }
149
+
150
+ return (
151
+ startFileEntry['File_size'] -
152
+ this.position.offset -
153
+ endFileEntry['File_size'] +
154
+ endPosition.offset +
155
+ logFiles.slice(startFileIndex + 1, endFileIndex).reduce((sum, file) => sum + file['File_size'], 0)
156
+ );
157
+ }
158
+ }
@@ -0,0 +1,59 @@
1
+ import mysqlPromise from 'mysql2/promise';
2
+ import * as mysql_utils from '../utils/mysql_utils.js';
3
+
4
+ export async function checkSourceConfiguration(connection: mysqlPromise.Connection) {
5
+ const errors: string[] = [];
6
+ const [[result]] = await mysql_utils.retriedQuery({
7
+ connection,
8
+ query: `
9
+ SELECT
10
+ @@GLOBAL.gtid_mode AS gtid_mode,
11
+ @@GLOBAL.log_bin AS log_bin,
12
+ @@GLOBAL.server_id AS server_id,
13
+ @@GLOBAL.log_bin_basename AS binlog_file,
14
+ @@GLOBAL.log_bin_index AS binlog_index_file
15
+ `
16
+ });
17
+
18
+ if (result.gtid_mode != 'ON') {
19
+ errors.push(`GTID is not enabled, it is currently set to ${result.gtid_mode}. Please enable it.`);
20
+ }
21
+
22
+ if (result.log_bin != 1) {
23
+ errors.push('Binary logging is not enabled. Please enable it.');
24
+ }
25
+
26
+ if (result.server_id < 0) {
27
+ errors.push(
28
+ `Your Server ID setting is too low, it must be greater than 0. It is currently ${result.server_id}. Please correct your configuration.`
29
+ );
30
+ }
31
+
32
+ if (!result.binlog_file) {
33
+ errors.push('Binary log file is not set. Please check your settings.');
34
+ }
35
+
36
+ if (!result.binlog_index_file) {
37
+ errors.push('Binary log index file is not set. Please check your settings.');
38
+ }
39
+
40
+ const [[binLogFormatResult]] = await mysql_utils.retriedQuery({
41
+ connection,
42
+ query: `SHOW VARIABLES LIKE 'binlog_format';`
43
+ });
44
+
45
+ if (binLogFormatResult.Value !== 'ROW') {
46
+ errors.push('Binary log format must be set to "ROW". Please correct your configuration');
47
+ }
48
+
49
+ return errors;
50
+ }
51
+
52
+ export async function getMySQLVersion(connection: mysqlPromise.Connection): Promise<string> {
53
+ const [[versionResult]] = await mysql_utils.retriedQuery({
54
+ connection,
55
+ query: `SELECT VERSION() as version`
56
+ });
57
+
58
+ return versionResult.version as string;
59
+ }
@@ -0,0 +1,6 @@
1
+ export * from './check-source-configuration.js';
2
+ export * from './get-replication-columns.js';
3
+ export * from './get-tables-from-pattern.js';
4
+ export * from './mysql-to-sqlite.js';
5
+ export * from './read-executed-gtid.js';
6
+ export * from './ReplicatedGTID.js';
@@ -0,0 +1,124 @@
1
+ import { storage } from '@powersync/service-core';
2
+ import mysqlPromise from 'mysql2/promise';
3
+ import * as mysql_utils from '../utils/mysql_utils.js';
4
+
5
+ export type GetReplicationColumnsOptions = {
6
+ connection: mysqlPromise.Connection;
7
+ schema: string;
8
+ table_name: string;
9
+ };
10
+
11
+ export type ReplicationIdentityColumnsResult = {
12
+ columns: storage.ColumnDescriptor[];
13
+ // TODO maybe export an enum from the core package
14
+ identity: string;
15
+ };
16
+
17
+ export async function getReplicationIdentityColumns(
18
+ options: GetReplicationColumnsOptions
19
+ ): Promise<ReplicationIdentityColumnsResult> {
20
+ const { connection, schema, table_name } = options;
21
+ const [primaryKeyColumns] = await mysql_utils.retriedQuery({
22
+ connection: connection,
23
+ query: `
24
+ SELECT
25
+ s.COLUMN_NAME AS name,
26
+ c.DATA_TYPE AS type
27
+ FROM
28
+ INFORMATION_SCHEMA.STATISTICS s
29
+ JOIN
30
+ INFORMATION_SCHEMA.COLUMNS c
31
+ ON
32
+ s.TABLE_SCHEMA = c.TABLE_SCHEMA
33
+ AND s.TABLE_NAME = c.TABLE_NAME
34
+ AND s.COLUMN_NAME = c.COLUMN_NAME
35
+ WHERE
36
+ s.TABLE_SCHEMA = ?
37
+ AND s.TABLE_NAME = ?
38
+ AND s.INDEX_NAME = 'PRIMARY'
39
+ ORDER BY
40
+ s.SEQ_IN_INDEX;
41
+ `,
42
+ params: [schema, table_name]
43
+ });
44
+
45
+ if (primaryKeyColumns.length) {
46
+ return {
47
+ columns: primaryKeyColumns.map((row) => ({
48
+ name: row.name,
49
+ type: row.type
50
+ })),
51
+ identity: 'default'
52
+ };
53
+ }
54
+
55
+ // TODO: test code with tables with unique keys, compound key etc.
56
+ // No primary key, find the first valid unique key
57
+ const [uniqueKeyColumns] = await mysql_utils.retriedQuery({
58
+ connection: connection,
59
+ query: `
60
+ SELECT
61
+ s.INDEX_NAME,
62
+ s.COLUMN_NAME,
63
+ c.DATA_TYPE,
64
+ s.NON_UNIQUE,
65
+ s.NULLABLE
66
+ FROM
67
+ INFORMATION_SCHEMA.STATISTICS s
68
+ JOIN
69
+ INFORMATION_SCHEMA.COLUMNS c
70
+ ON
71
+ s.TABLE_SCHEMA = c.TABLE_SCHEMA
72
+ AND s.TABLE_NAME = c.TABLE_NAME
73
+ AND s.COLUMN_NAME = c.COLUMN_NAME
74
+ WHERE
75
+ s.TABLE_SCHEMA = ?
76
+ AND s.TABLE_NAME = ?
77
+ AND s.INDEX_NAME != 'PRIMARY'
78
+ AND s.NON_UNIQUE = 0
79
+ ORDER BY s.SEQ_IN_INDEX;
80
+ `,
81
+ params: [schema, table_name]
82
+ });
83
+
84
+ if (uniqueKeyColumns.length > 0) {
85
+ return {
86
+ columns: uniqueKeyColumns.map((col) => ({
87
+ name: col.COLUMN_NAME,
88
+ type: col.DATA_TYPE
89
+ })),
90
+ identity: 'index'
91
+ };
92
+ }
93
+
94
+ const [allColumns] = await mysql_utils.retriedQuery({
95
+ connection: connection,
96
+ query: `
97
+ SELECT
98
+ s.COLUMN_NAME AS name,
99
+ c.DATA_TYPE as type
100
+ FROM
101
+ INFORMATION_SCHEMA.COLUMNS s
102
+ JOIN
103
+ INFORMATION_SCHEMA.COLUMNS c
104
+ ON
105
+ s.TABLE_SCHEMA = c.TABLE_SCHEMA
106
+ AND s.TABLE_NAME = c.TABLE_NAME
107
+ AND s.COLUMN_NAME = c.COLUMN_NAME
108
+ WHERE
109
+ s.TABLE_SCHEMA = ?
110
+ AND s.TABLE_NAME = ?
111
+ ORDER BY
112
+ s.ORDINAL_POSITION;
113
+ `,
114
+ params: [schema, table_name]
115
+ });
116
+
117
+ return {
118
+ columns: allColumns.map((row) => ({
119
+ name: row.name,
120
+ type: row.type
121
+ })),
122
+ identity: 'full'
123
+ };
124
+ }