@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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +67 -0
- package/README.md +3 -0
- package/dev/.env.template +2 -0
- package/dev/README.md +9 -0
- package/dev/config/sync_rules.yaml +12 -0
- package/dev/docker/mysql/docker-compose.yaml +17 -0
- package/dev/docker/mysql/init-scripts/my.cnf +9 -0
- package/dev/docker/mysql/init-scripts/mysql.sql +38 -0
- package/dist/api/MySQLRouteAPIAdapter.d.ts +24 -0
- package/dist/api/MySQLRouteAPIAdapter.js +311 -0
- package/dist/api/MySQLRouteAPIAdapter.js.map +1 -0
- package/dist/common/ReplicatedGTID.d.ts +59 -0
- package/dist/common/ReplicatedGTID.js +110 -0
- package/dist/common/ReplicatedGTID.js.map +1 -0
- package/dist/common/check-source-configuration.d.ts +3 -0
- package/dist/common/check-source-configuration.js +46 -0
- package/dist/common/check-source-configuration.js.map +1 -0
- package/dist/common/common-index.d.ts +6 -0
- package/dist/common/common-index.js +7 -0
- package/dist/common/common-index.js.map +1 -0
- package/dist/common/get-replication-columns.d.ts +12 -0
- package/dist/common/get-replication-columns.js +103 -0
- package/dist/common/get-replication-columns.js.map +1 -0
- package/dist/common/get-tables-from-pattern.d.ts +7 -0
- package/dist/common/get-tables-from-pattern.js +28 -0
- package/dist/common/get-tables-from-pattern.js.map +1 -0
- package/dist/common/mysql-to-sqlite.d.ts +4 -0
- package/dist/common/mysql-to-sqlite.js +56 -0
- package/dist/common/mysql-to-sqlite.js.map +1 -0
- package/dist/common/read-executed-gtid.d.ts +6 -0
- package/dist/common/read-executed-gtid.js +40 -0
- package/dist/common/read-executed-gtid.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/module/MySQLModule.d.ts +13 -0
- package/dist/module/MySQLModule.js +46 -0
- package/dist/module/MySQLModule.js.map +1 -0
- package/dist/replication/BinLogReplicationJob.d.ts +14 -0
- package/dist/replication/BinLogReplicationJob.js +88 -0
- package/dist/replication/BinLogReplicationJob.js.map +1 -0
- package/dist/replication/BinLogReplicator.d.ts +13 -0
- package/dist/replication/BinLogReplicator.js +25 -0
- package/dist/replication/BinLogReplicator.js.map +1 -0
- package/dist/replication/BinLogStream.d.ts +43 -0
- package/dist/replication/BinLogStream.js +421 -0
- package/dist/replication/BinLogStream.js.map +1 -0
- package/dist/replication/MySQLConnectionManager.d.ts +43 -0
- package/dist/replication/MySQLConnectionManager.js +81 -0
- package/dist/replication/MySQLConnectionManager.js.map +1 -0
- package/dist/replication/MySQLConnectionManagerFactory.d.ts +10 -0
- package/dist/replication/MySQLConnectionManagerFactory.js +21 -0
- package/dist/replication/MySQLConnectionManagerFactory.js.map +1 -0
- package/dist/replication/MySQLErrorRateLimiter.d.ts +10 -0
- package/dist/replication/MySQLErrorRateLimiter.js +43 -0
- package/dist/replication/MySQLErrorRateLimiter.js.map +1 -0
- package/dist/replication/zongji/zongji-utils.d.ts +7 -0
- package/dist/replication/zongji/zongji-utils.js +19 -0
- package/dist/replication/zongji/zongji-utils.js.map +1 -0
- package/dist/types/types.d.ts +50 -0
- package/dist/types/types.js +61 -0
- package/dist/types/types.js.map +1 -0
- package/dist/utils/mysql_utils.d.ts +14 -0
- package/dist/utils/mysql_utils.js +38 -0
- package/dist/utils/mysql_utils.js.map +1 -0
- package/package.json +51 -0
- package/src/api/MySQLRouteAPIAdapter.ts +357 -0
- package/src/common/ReplicatedGTID.ts +158 -0
- package/src/common/check-source-configuration.ts +59 -0
- package/src/common/common-index.ts +6 -0
- package/src/common/get-replication-columns.ts +124 -0
- package/src/common/get-tables-from-pattern.ts +44 -0
- package/src/common/mysql-to-sqlite.ts +59 -0
- package/src/common/read-executed-gtid.ts +43 -0
- package/src/index.ts +5 -0
- package/src/module/MySQLModule.ts +53 -0
- package/src/replication/BinLogReplicationJob.ts +97 -0
- package/src/replication/BinLogReplicator.ts +35 -0
- package/src/replication/BinLogStream.ts +547 -0
- package/src/replication/MySQLConnectionManager.ts +104 -0
- package/src/replication/MySQLConnectionManagerFactory.ts +28 -0
- package/src/replication/MySQLErrorRateLimiter.ts +44 -0
- package/src/replication/zongji/zongji-utils.ts +32 -0
- package/src/replication/zongji/zongji.d.ts +98 -0
- package/src/types/types.ts +102 -0
- package/src/utils/mysql_utils.ts +47 -0
- package/test/src/binlog_stream.test.ts +288 -0
- package/test/src/binlog_stream_utils.ts +152 -0
- package/test/src/env.ts +7 -0
- package/test/src/setup.ts +7 -0
- package/test/src/util.ts +62 -0
- package/test/tsconfig.json +28 -0
- package/tsconfig.json +26 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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,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
|
+
}
|