@powersync/service-module-mysql 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,18 @@ import * as urijs from 'uri-js';
6
6
 
7
7
  export const MYSQL_CONNECTION_TYPE = 'mysql' as const;
8
8
 
9
+ /**
10
+ * Connection parameters that can be parsed from the MySQL URI query string.
11
+ *
12
+ * All values are in milliseconds (for timeouts) or counts (for limits).
13
+ * MySQL uses camelCase naming convention.
14
+ */
15
+ export interface MySQLConnectionParams {
16
+ connectTimeout?: number;
17
+ connectionLimit?: number;
18
+ queueLimit?: number;
19
+ }
20
+
9
21
  export interface NormalizedMySQLConnectionConfig {
10
22
  id: string;
11
23
  tag: string;
@@ -25,6 +37,8 @@ export interface NormalizedMySQLConnectionConfig {
25
37
  lookup?: LookupFunction;
26
38
 
27
39
  binlog_queue_memory_limit: number;
40
+
41
+ connectionParams: MySQLConnectionParams;
28
42
  }
29
43
 
30
44
  export const MySQLConnectionConfig = service_types.configFile.DataSourceConfig.and(
@@ -105,6 +119,10 @@ export function normalizeConnectionConfig(options: MySQLConnectionConfig): Norma
105
119
 
106
120
  const lookup = makeHostnameLookupFunction(hostname, { reject_ip_ranges: options.reject_ip_ranges ?? [] });
107
121
 
122
+ // Parse connection parameters from URL query string
123
+ const uriQuery = uri.query ? new URLSearchParams(uri.query) : undefined;
124
+ const connectionParams = parseMySQLConnectionParams(uriQuery);
125
+
108
126
  return {
109
127
  id: options.id ?? 'default',
110
128
  tag: options.tag ?? 'default',
@@ -121,6 +139,55 @@ export function normalizeConnectionConfig(options: MySQLConnectionConfig): Norma
121
139
  // Binlog processing queue memory limit before throttling is applied.
122
140
  binlog_queue_memory_limit: options.binlog_queue_memory_limit ?? 50,
123
141
 
124
- lookup
142
+ lookup,
143
+
144
+ connectionParams
125
145
  };
126
146
  }
147
+
148
+ /**
149
+ * Parse a single numeric connection parameter from a URI query string value.
150
+ *
151
+ * Returns undefined if the value is missing, not a valid number, NaN, negative, zero, or Infinity.
152
+ */
153
+ export function parseMySQLConnectionParam(value: string | null | undefined): number | undefined {
154
+ if (value == null) {
155
+ return undefined;
156
+ }
157
+ const parsed = Number(value);
158
+ if (isFinite(parsed) && parsed > 0) {
159
+ return parsed;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ /**
165
+ * Parse connection parameters from a MySQL URI's query string.
166
+ *
167
+ * MySQL uses camelCase naming convention.
168
+ * Invalid values (NaN, negative, non-numeric) are silently ignored.
169
+ */
170
+ export function parseMySQLConnectionParams(searchParams: URLSearchParams | undefined): MySQLConnectionParams {
171
+ const params: MySQLConnectionParams = {};
172
+
173
+ if (searchParams == null) {
174
+ return params;
175
+ }
176
+
177
+ const connectTimeout = parseMySQLConnectionParam(searchParams.get('connectTimeout'));
178
+ if (connectTimeout != null) {
179
+ params.connectTimeout = connectTimeout;
180
+ }
181
+
182
+ const connectionLimit = parseMySQLConnectionParam(searchParams.get('connectionLimit'));
183
+ if (connectionLimit != null) {
184
+ params.connectionLimit = connectionLimit;
185
+ }
186
+
187
+ const queueLimit = parseMySQLConnectionParam(searchParams.get('queueLimit'));
188
+ if (queueLimit != null) {
189
+ params.queueLimit = queueLimit;
190
+ }
191
+
192
+ return params;
193
+ }
@@ -37,6 +37,10 @@ export function createPool(config: types.NormalizedMySQLConnectionConfig, option
37
37
  cert: config.client_certificate
38
38
  };
39
39
  const hasSSLOptions = Object.values(sslOptions).some((v) => !!v);
40
+
41
+ // URL connection parameters provide defaults; explicit options take precedence
42
+ const params = config.connectionParams;
43
+
40
44
  // TODO: Use config.lookup for DNS resolution
41
45
  return mysql.createPool({
42
46
  host: config.hostname,
@@ -50,6 +54,10 @@ export function createPool(config: types.NormalizedMySQLConnectionConfig, option
50
54
  timezone: 'Z', // Ensure no auto timezone manipulation of the dates occur
51
55
  jsonStrings: true, // Return JSON columns as strings
52
56
  dateStrings: true, // We parse and format them ourselves
57
+ // Apply URL connection parameters (explicit options override these via spread below)
58
+ ...(params.connectTimeout != null ? { connectTimeout: params.connectTimeout } : {}),
59
+ ...(params.connectionLimit != null ? { connectionLimit: params.connectionLimit } : {}),
60
+ ...(params.queueLimit != null ? { queueLimit: params.queueLimit } : {}),
53
61
  ...(options || {})
54
62
  });
55
63
  }
@@ -18,7 +18,9 @@ describe('BinLogStream tests', () => {
18
18
  describeWithStorage({ timeout: 20_000 }, defineBinlogStreamTests);
19
19
  });
20
20
 
21
- function defineBinlogStreamTests(factory: storage.TestStorageFactory) {
21
+ function defineBinlogStreamTests(config: storage.TestStorageConfig) {
22
+ const factory = config.factory;
23
+
22
24
  test('Replicate basic values', async () => {
23
25
  await using context = await BinlogStreamTestContext.open(factory);
24
26
  const { connectionManager } = context;
@@ -7,13 +7,15 @@ import {
7
7
  createCoreReplicationMetrics,
8
8
  initializeCoreReplicationMetrics,
9
9
  InternalOpId,
10
+ LEGACY_STORAGE_VERSION,
10
11
  OplogEntry,
11
12
  ProtocolOpId,
12
13
  ReplicationCheckpoint,
13
14
  storage,
14
- SyncRulesBucketStorage
15
+ SyncRulesBucketStorage,
16
+ updateSyncRulesFromYaml
15
17
  } from '@powersync/service-core';
16
- import { METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
18
+ import { bucketRequest, METRICS_HELPER, test_utils } from '@powersync/service-core-tests';
17
19
  import mysqlPromise from 'mysql2/promise';
18
20
  import { clearTestDb, TEST_CONNECTION_OPTIONS } from './util.js';
19
21
  import timers from 'timers/promises';
@@ -31,6 +33,7 @@ export class BinlogStreamTestContext {
31
33
  private streamPromise?: Promise<void>;
32
34
  public storage?: SyncRulesBucketStorage;
33
35
  private replicationDone = false;
36
+ private syncRulesContent?: storage.PersistedSyncRulesContent;
34
37
 
35
38
  static async open(factory: storage.TestStorageFactory, options?: { doNotClear?: boolean }) {
36
39
  const f = await factory({ doNotClear: options?.doNotClear });
@@ -68,7 +71,10 @@ export class BinlogStreamTestContext {
68
71
  }
69
72
 
70
73
  async updateSyncRules(content: string): Promise<SyncRulesBucketStorage> {
71
- const syncRules = await this.factory.updateSyncRules({ content: content, validate: true });
74
+ const syncRules = await this.factory.updateSyncRules(
75
+ updateSyncRulesFromYaml(content, { validate: true, storageVersion: LEGACY_STORAGE_VERSION })
76
+ );
77
+ this.syncRulesContent = syncRules;
72
78
  this.storage = this.factory.getInstance(syncRules);
73
79
  return this.storage!;
74
80
  }
@@ -79,6 +85,7 @@ export class BinlogStreamTestContext {
79
85
  throw new Error(`Next sync rules not available`);
80
86
  }
81
87
 
88
+ this.syncRulesContent = syncRules;
82
89
  this.storage = this.factory.getInstance(syncRules);
83
90
  return this.storage!;
84
91
  }
@@ -89,11 +96,19 @@ export class BinlogStreamTestContext {
89
96
  throw new Error(`Active sync rules not available`);
90
97
  }
91
98
 
99
+ this.syncRulesContent = syncRules;
92
100
  this.storage = this.factory.getInstance(syncRules);
93
101
  this.replicationDone = true;
94
102
  return this.storage!;
95
103
  }
96
104
 
105
+ private getSyncRulesContent(): storage.PersistedSyncRulesContent {
106
+ if (this.syncRulesContent == null) {
107
+ throw new Error('Sync rules not configured - call updateSyncRules() first');
108
+ }
109
+ return this.syncRulesContent;
110
+ }
111
+
97
112
  get binlogStream(): BinLogStream {
98
113
  if (this.storage == null) {
99
114
  throw new Error('updateSyncRules() first');
@@ -150,7 +165,8 @@ export class BinlogStreamTestContext {
150
165
 
151
166
  async getBucketsDataBatch(buckets: Record<string, InternalOpId>, options?: { timeout?: number }) {
152
167
  const checkpoint = await this.getCheckpoint(options);
153
- const map = new Map<string, InternalOpId>(Object.entries(buckets));
168
+ const syncRules = this.getSyncRulesContent();
169
+ const map = Object.entries(buckets).map(([bucket, start]) => bucketRequest(syncRules, bucket, start));
154
170
  return test_utils.fromAsync(this.storage!.getBucketDataBatch(checkpoint, map));
155
171
  }
156
172
 
@@ -163,8 +179,9 @@ export class BinlogStreamTestContext {
163
179
  if (typeof start == 'string') {
164
180
  start = BigInt(start);
165
181
  }
182
+ const syncRules = this.getSyncRulesContent();
166
183
  const checkpoint = await this.getCheckpoint(options);
167
- const map = new Map<string, InternalOpId>([[bucket, start]]);
184
+ const map = [bucketRequest(syncRules, bucket, start)];
168
185
  const batch = this.storage!.getBucketDataBatch(checkpoint, map);
169
186
  const batches = await test_utils.fromAsync(batch);
170
187
  return batches[0]?.chunkData.data ?? [];
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ normalizeConnectionConfig,
4
+ parseMySQLConnectionParam,
5
+ parseMySQLConnectionParams
6
+ } from '@module/types/types.js';
7
+
8
+ describe('config', () => {
9
+ test('Should resolve database', () => {
10
+ const normalized = normalizeConnectionConfig({
11
+ type: 'mysql',
12
+ uri: 'mysql://user:pass@localhost:3306/mydb'
13
+ });
14
+ expect(normalized.database).equals('mydb');
15
+ });
16
+
17
+ describe('connection parameters', () => {
18
+ test('parses all connection parameters from URL query string', () => {
19
+ const normalized = normalizeConnectionConfig({
20
+ type: 'mysql',
21
+ uri: 'mysql://user:pass@localhost:3306/mydb?connectTimeout=5000&connectionLimit=20&queueLimit=100'
22
+ });
23
+ expect(normalized.connectionParams.connectTimeout).equals(5000);
24
+ expect(normalized.connectionParams.connectionLimit).equals(20);
25
+ expect(normalized.connectionParams.queueLimit).equals(100);
26
+ });
27
+
28
+ test('URL without connection parameters returns empty connectionParams', () => {
29
+ const normalized = normalizeConnectionConfig({
30
+ type: 'mysql',
31
+ uri: 'mysql://user:pass@localhost:3306/mydb'
32
+ });
33
+ expect(normalized.connectionParams).toEqual({});
34
+ });
35
+
36
+ test('parameters can be partially specified', () => {
37
+ const normalized = normalizeConnectionConfig({
38
+ type: 'mysql',
39
+ uri: 'mysql://user:pass@localhost:3306/mydb?connectTimeout=5000&queueLimit=50'
40
+ });
41
+ expect(normalized.connectionParams.connectTimeout).equals(5000);
42
+ expect(normalized.connectionParams.queueLimit).equals(50);
43
+ expect(normalized.connectionParams.connectionLimit).toBeUndefined();
44
+ });
45
+
46
+ test('ignores invalid (non-numeric) connection parameter values', () => {
47
+ const normalized = normalizeConnectionConfig({
48
+ type: 'mysql',
49
+ uri: 'mysql://user:pass@localhost:3306/mydb?connectTimeout=abc&connectionLimit=xyz'
50
+ });
51
+ expect(normalized.connectionParams.connectTimeout).toBeUndefined();
52
+ expect(normalized.connectionParams.connectionLimit).toBeUndefined();
53
+ });
54
+
55
+ test('ignores negative connection parameter values', () => {
56
+ const normalized = normalizeConnectionConfig({
57
+ type: 'mysql',
58
+ uri: 'mysql://user:pass@localhost:3306/mydb?connectTimeout=-5000&connectionLimit=-10'
59
+ });
60
+ expect(normalized.connectionParams.connectTimeout).toBeUndefined();
61
+ expect(normalized.connectionParams.connectionLimit).toBeUndefined();
62
+ });
63
+
64
+ test('ignores zero connection parameter values', () => {
65
+ const normalized = normalizeConnectionConfig({
66
+ type: 'mysql',
67
+ uri: 'mysql://user:pass@localhost:3306/mydb?connectTimeout=0&connectionLimit=0&queueLimit=0'
68
+ });
69
+ expect(normalized.connectionParams.connectTimeout).toBeUndefined();
70
+ expect(normalized.connectionParams.connectionLimit).toBeUndefined();
71
+ expect(normalized.connectionParams.queueLimit).toBeUndefined();
72
+ });
73
+
74
+ test('works without URI (config-only)', () => {
75
+ const normalized = normalizeConnectionConfig({
76
+ type: 'mysql',
77
+ hostname: 'localhost',
78
+ port: 3306,
79
+ database: 'mydb',
80
+ username: 'user',
81
+ password: 'pass'
82
+ });
83
+ expect(normalized.connectionParams).toEqual({});
84
+ });
85
+ });
86
+ });
87
+
88
+ describe('parseMySQLConnectionParam', () => {
89
+ test('returns undefined when no value provided', () => {
90
+ expect(parseMySQLConnectionParam(undefined)).toBeUndefined();
91
+ expect(parseMySQLConnectionParam(null)).toBeUndefined();
92
+ });
93
+
94
+ test('parses valid numeric string', () => {
95
+ expect(parseMySQLConnectionParam('5000')).equals(5000);
96
+ });
97
+
98
+ test('parses fractional values', () => {
99
+ expect(parseMySQLConnectionParam('1500.5')).equals(1500.5);
100
+ });
101
+
102
+ test('ignores non-numeric string', () => {
103
+ expect(parseMySQLConnectionParam('abc')).toBeUndefined();
104
+ });
105
+
106
+ test('ignores empty string', () => {
107
+ expect(parseMySQLConnectionParam('')).toBeUndefined();
108
+ });
109
+
110
+ test('ignores negative value', () => {
111
+ expect(parseMySQLConnectionParam('-5000')).toBeUndefined();
112
+ });
113
+
114
+ test('ignores zero', () => {
115
+ expect(parseMySQLConnectionParam('0')).toBeUndefined();
116
+ });
117
+
118
+ test('ignores Infinity', () => {
119
+ expect(parseMySQLConnectionParam('Infinity')).toBeUndefined();
120
+ });
121
+
122
+ test('ignores NaN', () => {
123
+ expect(parseMySQLConnectionParam('NaN')).toBeUndefined();
124
+ });
125
+ });
126
+
127
+ describe('parseMySQLConnectionParams', () => {
128
+ test('parses all supported parameters', () => {
129
+ const params = new URLSearchParams('connectTimeout=5000&connectionLimit=20&queueLimit=100');
130
+ const result = parseMySQLConnectionParams(params);
131
+ expect(result).toEqual({
132
+ connectTimeout: 5000,
133
+ connectionLimit: 20,
134
+ queueLimit: 100
135
+ });
136
+ });
137
+
138
+ test('returns empty object when no connection params present', () => {
139
+ const params = new URLSearchParams('someOther=value');
140
+ const result = parseMySQLConnectionParams(params);
141
+ expect(result).toEqual({});
142
+ });
143
+
144
+ test('returns empty object for undefined searchParams', () => {
145
+ const result = parseMySQLConnectionParams(undefined);
146
+ expect(result).toEqual({});
147
+ });
148
+
149
+ test('ignores invalid values and only includes valid ones', () => {
150
+ const params = new URLSearchParams('connectTimeout=5000&connectionLimit=abc&queueLimit=-10');
151
+ const result = parseMySQLConnectionParams(params);
152
+ expect(result).toEqual({
153
+ connectTimeout: 5000
154
+ });
155
+ });
156
+
157
+ test('handles partial parameter specification', () => {
158
+ const params = new URLSearchParams('connectTimeout=5000&queueLimit=100');
159
+ const result = parseMySQLConnectionParams(params);
160
+ expect(result).toEqual({
161
+ connectTimeout: 5000,
162
+ queueLimit: 100
163
+ });
164
+ });
165
+ });
@@ -26,7 +26,8 @@ const PUT_T3 = test_utils.putOp('test_data', { id: 't3', description: 'test3' })
26
26
  const REMOVE_T1 = test_utils.removeOp('test_data', 't1');
27
27
  const REMOVE_T2 = test_utils.removeOp('test_data', 't2');
28
28
 
29
- function defineTests(factory: storage.TestStorageFactory) {
29
+ function defineTests(config: storage.TestStorageConfig) {
30
+ const factory = config.factory;
30
31
  let isMySQL57: boolean = false;
31
32
 
32
33
  beforeAll(async () => {
package/test/src/util.ts CHANGED
@@ -1,16 +1,16 @@
1
+ import * as common from '@module/common/common-index.js';
2
+ import { MySQLConnectionManager } from '@module/replication/MySQLConnectionManager.js';
3
+ import { BinLogEventHandler, BinLogListener, Row, SchemaChange } from '@module/replication/zongji/BinLogListener.js';
1
4
  import * as types from '@module/types/types.js';
2
5
  import { createRandomServerId, getMySQLVersion, isVersionAtLeast } from '@module/utils/mysql-utils.js';
6
+ import { TableMapEntry } from '@powersync/mysql-zongji';
7
+ import { TestStorageConfig } from '@powersync/service-core';
3
8
  import * as mongo_storage from '@powersync/service-module-mongodb-storage';
4
9
  import * as postgres_storage from '@powersync/service-module-postgres-storage';
10
+ import { TablePattern } from '@powersync/service-sync-rules';
5
11
  import mysqlPromise from 'mysql2/promise';
6
- import { env } from './env.js';
7
12
  import { describe, TestOptions } from 'vitest';
8
- import { TestStorageFactory } from '@powersync/service-core';
9
- import { MySQLConnectionManager } from '@module/replication/MySQLConnectionManager.js';
10
- import { BinLogEventHandler, BinLogListener, Row, SchemaChange } from '@module/replication/zongji/BinLogListener.js';
11
- import { TableMapEntry } from '@powersync/mysql-zongji';
12
- import * as common from '@module/common/common-index.js';
13
- import { TablePattern } from '@powersync/service-sync-rules';
13
+ import { env } from './env.js';
14
14
 
15
15
  export const TEST_URI = env.MYSQL_TEST_URI;
16
16
 
@@ -24,11 +24,11 @@ export const INITIALIZED_MONGO_STORAGE_FACTORY = mongo_storage.test_utils.mongoT
24
24
  isCI: env.CI
25
25
  });
26
26
 
27
- export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.postgresTestStorageFactoryGenerator({
27
+ export const INITIALIZED_POSTGRES_STORAGE_FACTORY = postgres_storage.test_utils.postgresTestSetup({
28
28
  url: env.PG_STORAGE_TEST_URL
29
29
  });
30
30
 
31
- export function describeWithStorage(options: TestOptions, fn: (factory: TestStorageFactory) => void) {
31
+ export function describeWithStorage(options: TestOptions, fn: (factory: TestStorageConfig) => void) {
32
32
  describe.skipIf(!env.TEST_MONGO_STORAGE)(`mongodb storage`, options, function () {
33
33
  fn(INITIALIZED_MONGO_STORAGE_FACTORY);
34
34
  });