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

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.
@@ -2,16 +2,155 @@ import * as sync_rules from '@powersync/service-sync-rules';
2
2
  import { ExpressionType } from '@powersync/service-sync-rules';
3
3
  import { ColumnDescriptor } from '@powersync/service-core';
4
4
  import mysql from 'mysql2';
5
+ import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
6
+ import { ColumnDefinition, TableMapEntry } from '@powersync/mysql-zongji';
5
7
 
6
- export function toSQLiteRow(row: Record<string, any>, columns?: Map<string, ColumnDescriptor>): sync_rules.SqliteRow {
8
+ export enum ADDITIONAL_MYSQL_TYPES {
9
+ DATETIME2 = 18,
10
+ TIMESTAMP2 = 17,
11
+ BINARY = 100,
12
+ VARBINARY = 101,
13
+ TEXT = 102
14
+ }
15
+
16
+ export const MySQLTypesMap: { [key: number]: string } = {};
17
+ for (const [name, code] of Object.entries(mysql.Types)) {
18
+ MySQLTypesMap[code as number] = name;
19
+ }
20
+ for (const [name, code] of Object.entries(ADDITIONAL_MYSQL_TYPES)) {
21
+ MySQLTypesMap[code as number] = name;
22
+ }
23
+
24
+ export function toColumnDescriptors(columns: mysql.FieldPacket[]): Map<string, ColumnDescriptor>;
25
+ export function toColumnDescriptors(tableMap: TableMapEntry): Map<string, ColumnDescriptor>;
26
+
27
+ export function toColumnDescriptors(columns: mysql.FieldPacket[] | TableMapEntry): Map<string, ColumnDescriptor> {
28
+ const columnMap = new Map<string, ColumnDescriptor>();
29
+ if (Array.isArray(columns)) {
30
+ for (const column of columns) {
31
+ columnMap.set(column.name, toColumnDescriptorFromFieldPacket(column));
32
+ }
33
+ } else {
34
+ for (const column of columns.columns) {
35
+ columnMap.set(column.name, toColumnDescriptorFromDefinition(column));
36
+ }
37
+ }
38
+
39
+ return columnMap;
40
+ }
41
+
42
+ export function toColumnDescriptorFromFieldPacket(column: mysql.FieldPacket): ColumnDescriptor {
43
+ let typeId = column.type!;
44
+ const BINARY_FLAG = 128;
45
+ const MYSQL_ENUM_FLAG = 256;
46
+ const MYSQL_SET_FLAG = 2048;
47
+
48
+ switch (column.type) {
49
+ case mysql.Types.STRING:
50
+ if (((column.flags as number) & BINARY_FLAG) !== 0) {
51
+ typeId = ADDITIONAL_MYSQL_TYPES.BINARY;
52
+ } else if (((column.flags as number) & MYSQL_ENUM_FLAG) !== 0) {
53
+ typeId = mysql.Types.ENUM;
54
+ } else if (((column.flags as number) & MYSQL_SET_FLAG) !== 0) {
55
+ typeId = mysql.Types.SET;
56
+ }
57
+ break;
58
+
59
+ case mysql.Types.VAR_STRING:
60
+ typeId = ((column.flags as number) & BINARY_FLAG) !== 0 ? ADDITIONAL_MYSQL_TYPES.VARBINARY : column.type;
61
+ break;
62
+ case mysql.Types.BLOB:
63
+ typeId = ((column.flags as number) & BINARY_FLAG) === 0 ? ADDITIONAL_MYSQL_TYPES.TEXT : column.type;
64
+ break;
65
+ }
66
+
67
+ const columnType = MySQLTypesMap[typeId];
68
+
69
+ return {
70
+ name: column.name,
71
+ type: columnType,
72
+ typeId: typeId
73
+ };
74
+ }
75
+
76
+ export function toColumnDescriptorFromDefinition(column: ColumnDefinition): ColumnDescriptor {
77
+ let typeId = column.type;
78
+
79
+ switch (column.type) {
80
+ case mysql.Types.STRING:
81
+ typeId = !column.charset ? ADDITIONAL_MYSQL_TYPES.BINARY : column.type;
82
+ break;
83
+ case mysql.Types.VAR_STRING:
84
+ case mysql.Types.VARCHAR:
85
+ typeId = !column.charset ? ADDITIONAL_MYSQL_TYPES.VARBINARY : column.type;
86
+ break;
87
+ case mysql.Types.BLOB:
88
+ typeId = column.charset ? ADDITIONAL_MYSQL_TYPES.TEXT : column.type;
89
+ break;
90
+ }
91
+
92
+ const columnType = MySQLTypesMap[typeId];
93
+
94
+ return {
95
+ name: column.name,
96
+ type: columnType,
97
+ typeId: typeId
98
+ };
99
+ }
100
+
101
+ export function toSQLiteRow(row: Record<string, any>, columns: Map<string, ColumnDescriptor>): sync_rules.SqliteRow {
7
102
  for (let key in row) {
8
- if (row[key] instanceof Date) {
9
- const column = columns?.get(key);
10
- if (column?.typeId == mysql.Types.DATE) {
11
- // Only parse the date part
12
- row[key] = row[key].toISOString().split('T')[0];
13
- } else {
14
- row[key] = row[key].toISOString();
103
+ // We are very much expecting the column to be there
104
+ const column = columns.get(key)!;
105
+
106
+ if (row[key] !== null) {
107
+ switch (column.typeId) {
108
+ case mysql.Types.DATE:
109
+ // Only parse the date part
110
+ row[key] = row[key].toISOString().split('T')[0];
111
+ break;
112
+ case mysql.Types.DATETIME:
113
+ case ADDITIONAL_MYSQL_TYPES.DATETIME2:
114
+ case mysql.Types.TIMESTAMP:
115
+ case ADDITIONAL_MYSQL_TYPES.TIMESTAMP2:
116
+ row[key] = row[key].toISOString();
117
+ break;
118
+ case mysql.Types.JSON:
119
+ if (typeof row[key] === 'string') {
120
+ row[key] = new JsonContainer(row[key]);
121
+ }
122
+ break;
123
+ case mysql.Types.BIT:
124
+ case mysql.Types.BLOB:
125
+ case mysql.Types.TINY_BLOB:
126
+ case mysql.Types.MEDIUM_BLOB:
127
+ case mysql.Types.LONG_BLOB:
128
+ case ADDITIONAL_MYSQL_TYPES.BINARY:
129
+ case ADDITIONAL_MYSQL_TYPES.VARBINARY:
130
+ row[key] = new Uint8Array(Object.values(row[key]));
131
+ break;
132
+ case mysql.Types.LONGLONG:
133
+ if (typeof row[key] === 'string') {
134
+ row[key] = BigInt(row[key]);
135
+ } else if (typeof row[key] === 'number') {
136
+ // Zongji returns BIGINT as a number when it can be represented as a number
137
+ row[key] = BigInt(row[key]);
138
+ }
139
+ break;
140
+ case mysql.Types.TINY:
141
+ case mysql.Types.SHORT:
142
+ case mysql.Types.LONG:
143
+ case mysql.Types.INT24:
144
+ // Handle all integer values a BigInt
145
+ if (typeof row[key] === 'number') {
146
+ row[key] = BigInt(row[key]);
147
+ }
148
+ break;
149
+ case mysql.Types.SET:
150
+ // Convert to JSON array from string
151
+ const values = row[key].split(',');
152
+ row[key] = JSONBig.stringify(values);
153
+ break;
15
154
  }
16
155
  }
17
156
  }
@@ -4,7 +4,6 @@ import { gte } from 'semver';
4
4
 
5
5
  import { ReplicatedGTID } from './ReplicatedGTID.js';
6
6
  import { getMySQLVersion } from './check-source-configuration.js';
7
- import { logger } from '@powersync/lib-services-framework';
8
7
 
9
8
  /**
10
9
  * Gets the current master HEAD GTID
@@ -33,8 +32,6 @@ export async function readExecutedGtid(connection: mysqlPromise.Connection): Pro
33
32
  offset: parseInt(binlogStatus.Position)
34
33
  };
35
34
 
36
- logger.info('Succesfully read executed GTID', { position });
37
-
38
35
  return new ReplicatedGTID({
39
36
  // The head always points to the next position to start replication from
40
37
  position,
@@ -9,9 +9,9 @@ import { BinLogEvent, StartOptions, TableMapEntry } from '@powersync/mysql-zongj
9
9
  import * as common from '../common/common-index.js';
10
10
  import * as zongji_utils from './zongji/zongji-utils.js';
11
11
  import { MySQLConnectionManager } from './MySQLConnectionManager.js';
12
- import { isBinlogStillAvailable, ReplicatedGTID } from '../common/common-index.js';
12
+ import { isBinlogStillAvailable, ReplicatedGTID, toColumnDescriptors } from '../common/common-index.js';
13
13
  import mysqlPromise from 'mysql2/promise';
14
- import { MySQLTypesMap } from '../utils/mysql_utils.js';
14
+ import { createRandomServerId } from '../utils/mysql_utils.js';
15
15
 
16
16
  export interface BinLogStreamOptions {
17
17
  connections: MySQLConnectionManager;
@@ -221,7 +221,13 @@ AND table_type = 'BASE TABLE';`,
221
221
  // Check if the binlog is still available. If it isn't we need to snapshot again.
222
222
  const connection = await this.connections.getConnection();
223
223
  try {
224
- return await isBinlogStillAvailable(connection, lastKnowGTID.position.filename);
224
+ const isAvailable = await isBinlogStillAvailable(connection, lastKnowGTID.position.filename);
225
+ if (!isAvailable) {
226
+ logger.info(
227
+ `Binlog file ${lastKnowGTID.position.filename} is no longer available, starting initial replication again.`
228
+ );
229
+ }
230
+ return isAvailable;
225
231
  } finally {
226
232
  connection.release();
227
233
  }
@@ -245,7 +251,7 @@ AND table_type = 'BASE TABLE';`,
245
251
  const connection = await this.connections.getStreamingConnection();
246
252
  const promiseConnection = (connection as mysql.Connection).promise();
247
253
  const headGTID = await common.readExecutedGtid(promiseConnection);
248
- logger.info(`Using snapshot checkpoint GTID:: '${headGTID}'`);
254
+ logger.info(`Using snapshot checkpoint GTID: '${headGTID}'`);
249
255
  try {
250
256
  logger.info(`Starting initial replication`);
251
257
  await promiseConnection.query<mysqlPromise.RowDataPacket[]>(
@@ -285,7 +291,7 @@ AND table_type = 'BASE TABLE';`,
285
291
  logger.info(`Replicating ${table.qualifiedName}`);
286
292
  // TODO count rows and log progress at certain batch sizes
287
293
 
288
- const columns = new Map<string, ColumnDescriptor>();
294
+ let columns: Map<string, ColumnDescriptor>;
289
295
  return new Promise<void>((resolve, reject) => {
290
296
  // MAX_EXECUTION_TIME(0) hint disables execution timeout for this query
291
297
  connection
@@ -295,10 +301,7 @@ AND table_type = 'BASE TABLE';`,
295
301
  })
296
302
  .on('fields', (fields: FieldPacket[]) => {
297
303
  // Map the columns and their types
298
- fields.forEach((field) => {
299
- const columnType = MySQLTypesMap[field.type as number];
300
- columns.set(field.name, { name: field.name, type: columnType, typeId: field.type });
301
- });
304
+ columns = toColumnDescriptors(fields);
302
305
  })
303
306
  .on('result', async (row) => {
304
307
  connection.pause();
@@ -363,10 +366,14 @@ AND table_type = 'BASE TABLE';`,
363
366
  async streamChanges() {
364
367
  // Auto-activate as soon as initial replication is done
365
368
  await this.storage.autoActivate();
369
+ const serverId = createRandomServerId(this.storage.group_id);
370
+ logger.info(`Starting replication. Created replica client with serverId:${serverId}`);
366
371
 
367
372
  const connection = await this.connections.getConnection();
368
373
  const { checkpoint_lsn } = await this.storage.getStatus();
369
- logger.info(`Last known LSN from storage: ${checkpoint_lsn}`);
374
+ if (checkpoint_lsn) {
375
+ logger.info(`Existing checkpoint found: ${checkpoint_lsn}`);
376
+ }
370
377
 
371
378
  const fromGTID = checkpoint_lsn
372
379
  ? common.ReplicatedGTID.fromSerialized(checkpoint_lsn)
@@ -447,7 +454,7 @@ AND table_type = 'BASE TABLE';`,
447
454
 
448
455
  zongji.on('binlog', (evt: BinLogEvent) => {
449
456
  if (!this.stopped) {
450
- logger.info(`Pushing Binlog event ${evt.getEventName()}`);
457
+ logger.info(`Received Binlog event:${evt.getEventName()}`);
451
458
  queue.push(evt);
452
459
  } else {
453
460
  logger.info(`Replication is busy stopping, ignoring event ${evt.getEventName()}`);
@@ -458,16 +465,18 @@ AND table_type = 'BASE TABLE';`,
458
465
  // Powersync is shutting down, don't start replicating
459
466
  return;
460
467
  }
468
+
469
+ logger.info(`Reading binlog from: ${binLogPositionState.filename}:${binLogPositionState.offset}`);
470
+
461
471
  // Only listen for changes to tables in the sync rules
462
472
  const includedTables = [...this.tableCache.values()].map((table) => table.table);
463
- logger.info(`Starting replication from ${binLogPositionState.filename}:${binLogPositionState.offset}`);
464
473
  zongji.start({
465
474
  includeEvents: ['tablemap', 'writerows', 'updaterows', 'deleterows', 'xid', 'rotate', 'gtidlog'],
466
475
  excludeEvents: [],
467
476
  includeSchema: { [this.defaultSchema]: includedTables },
468
477
  filename: binLogPositionState.filename,
469
478
  position: binLogPositionState.offset,
470
- serverId: this.storage.group_id
479
+ serverId: serverId
471
480
  } satisfies StartOptions);
472
481
 
473
482
  // Forever young
@@ -516,10 +525,7 @@ AND table_type = 'BASE TABLE';`,
516
525
  tableEntry: TableMapEntry;
517
526
  }
518
527
  ): Promise<storage.FlushedResult | null> {
519
- const columns = new Map<string, ColumnDescriptor>();
520
- msg.tableEntry.columns.forEach((column) => {
521
- columns.set(column.name, { name: column.name, typeId: column.type });
522
- });
528
+ const columns = toColumnDescriptors(msg.tableEntry);
523
529
 
524
530
  for (const [index, row] of msg.data.entries()) {
525
531
  await this.writeChange(batch, {
@@ -560,8 +566,10 @@ AND table_type = 'BASE TABLE';`,
560
566
  Metrics.getInstance().rows_replicated_total.add(1);
561
567
  // "before" may be null if the replica id columns are unchanged
562
568
  // It's fine to treat that the same as an insert.
563
- const beforeUpdated = payload.previous_data ? common.toSQLiteRow(payload.previous_data) : undefined;
564
- const after = common.toSQLiteRow(payload.data);
569
+ const beforeUpdated = payload.previous_data
570
+ ? common.toSQLiteRow(payload.previous_data, payload.columns)
571
+ : undefined;
572
+ const after = common.toSQLiteRow(payload.data, payload.columns);
565
573
 
566
574
  return await batch.save({
567
575
  tag: storage.SaveOperationTag.UPDATE,
@@ -570,13 +578,13 @@ AND table_type = 'BASE TABLE';`,
570
578
  beforeReplicaId: beforeUpdated
571
579
  ? getUuidReplicaIdentityBson(beforeUpdated, payload.sourceTable.replicaIdColumns)
572
580
  : undefined,
573
- after: common.toSQLiteRow(payload.data),
581
+ after: common.toSQLiteRow(payload.data, payload.columns),
574
582
  afterReplicaId: getUuidReplicaIdentityBson(after, payload.sourceTable.replicaIdColumns)
575
583
  });
576
584
 
577
585
  case storage.SaveOperationTag.DELETE:
578
586
  Metrics.getInstance().rows_replicated_total.add(1);
579
- const beforeDeleted = common.toSQLiteRow(payload.data);
587
+ const beforeDeleted = common.toSQLiteRow(payload.data, payload.columns);
580
588
 
581
589
  return await batch.save({
582
590
  tag: storage.SaveOperationTag.DELETE,
@@ -1,6 +1,6 @@
1
1
  import { NormalizedMySQLConnectionConfig } from '../types/types.js';
2
2
  import mysqlPromise from 'mysql2/promise';
3
- import mysql, { RowDataPacket } from 'mysql2';
3
+ import mysql, { FieldPacket, RowDataPacket } from 'mysql2';
4
4
  import * as mysql_utils from '../utils/mysql_utils.js';
5
5
  import ZongJi from '@powersync/mysql-zongji';
6
6
  import { logger } from '@powersync/lib-services-framework';
@@ -61,7 +61,7 @@ export class MySQLConnectionManager {
61
61
  * @param query
62
62
  * @param params
63
63
  */
64
- async query(query: string, params?: any[]) {
64
+ async query(query: string, params?: any[]): Promise<[RowDataPacket[], FieldPacket[]]> {
65
65
  return this.promisePool.query<RowDataPacket[]>(query, params);
66
66
  }
67
67
 
@@ -3,11 +3,6 @@ import mysql from 'mysql2';
3
3
  import mysqlPromise from 'mysql2/promise';
4
4
  import * as types from '../types/types.js';
5
5
 
6
- export const MySQLTypesMap: { [key: number]: string } = {};
7
- for (const [name, code] of Object.entries(mysql.Types)) {
8
- MySQLTypesMap[code as number] = name;
9
- }
10
-
11
6
  export type RetriedQueryOptions = {
12
7
  connection: mysqlPromise.Connection;
13
8
  query: string;
@@ -47,7 +42,21 @@ export function createPool(config: types.NormalizedMySQLConnectionConfig, option
47
42
  database: config.database,
48
43
  ssl: hasSSLOptions ? sslOptions : undefined,
49
44
  supportBigNumbers: true,
45
+ decimalNumbers: true,
50
46
  timezone: 'Z', // Ensure no auto timezone manipulation of the dates occur
47
+ jsonStrings: true, // Return JSON columns as strings
51
48
  ...(options || {})
52
49
  });
53
50
  }
51
+
52
+ /**
53
+ * Return a random server id for a given sync rule id.
54
+ * Expected format is: <syncRuleId>00<random number>
55
+ * The max value for server id in MySQL is 2^32 - 1.
56
+ * We use the GTID format to keep track of our position in the binlog, no state is kept by the MySQL server, therefore
57
+ * it is ok to use a randomised server id every time.
58
+ * @param syncRuleId
59
+ */
60
+ export function createRandomServerId(syncRuleId: number): number {
61
+ return Number.parseInt(`${syncRuleId}00${Math.floor(Math.random() * 10000)}`);
62
+ }