@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.
- package/CHANGELOG.md +9 -8
- package/dev/config/sync_rules.yaml +2 -4
- package/dist/common/mysql-to-sqlite.d.ts +17 -1
- package/dist/common/mysql-to-sqlite.js +133 -8
- package/dist/common/mysql-to-sqlite.js.map +1 -1
- package/dist/common/read-executed-gtid.js +0 -2
- package/dist/common/read-executed-gtid.js.map +1 -1
- package/dist/replication/BinLogStream.js +25 -21
- package/dist/replication/BinLogStream.js.map +1 -1
- package/dist/replication/MySQLConnectionManager.d.ts +2 -2
- package/dist/utils/mysql_utils.d.ts +9 -3
- package/dist/utils/mysql_utils.js +13 -4
- package/dist/utils/mysql_utils.js.map +1 -1
- package/package.json +7 -6
- package/src/common/mysql-to-sqlite.ts +147 -8
- package/src/common/read-executed-gtid.ts +0 -3
- package/src/replication/BinLogStream.ts +29 -21
- package/src/replication/MySQLConnectionManager.ts +2 -2
- package/src/utils/mysql_utils.ts +14 -5
- package/test/src/BinLogStream.test.ts +332 -0
- package/test/src/BinlogStreamUtils.ts +157 -0
- package/test/src/MysqlTypeMappings.test.ts +322 -0
- package/test/src/env.ts +1 -1
- package/test/src/util.ts +13 -14
- package/test/tsconfig.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(`
|
|
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:
|
|
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 =
|
|
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
|
|
564
|
-
|
|
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
|
|
package/src/utils/mysql_utils.ts
CHANGED
|
@@ -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
|
+
}
|