@dyrected/db-mysql 2.13.0 → 2.13.3

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/LICENSE.md ADDED
@@ -0,0 +1,50 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: Dyrected
6
+ Licensed Work: Dyrected
7
+ Additional Use Grant: Production use and commercial use of the Licensed Work are permitted for your own internal business operations, your own websites and applications, and client websites or applications that you build or deploy on behalf of a client, provided that you do not, without a separate commercial license from the Licensor, (a) provide the Licensed Work as a hosted or managed service to third parties, (b) embed, white-label, sublicense, or otherwise redistribute the Licensed Work as part of a multi-tenant or customer-facing SaaS, platform, or developer service, or (c) enable third parties to create, manage, or operate independent projects, sites, workspaces, or environments using the Licensed Work as a material part of the service you provide.
8
+ Change Date: 2030-05-18
9
+ Change License: Apache License 2.0
10
+
11
+ ---
12
+
13
+ Business Source License 1.1
14
+
15
+ License text copyright © 2024 MariaDB plc, All Rights Reserved.
16
+ “Business Source License” is a trademark of MariaDB plc.
17
+
18
+ ### Terms
19
+
20
+ The Licensor hereby grants you the right to copy, modify, create derivative works, redistribute, and make non-production use of the Licensed Work. The Licensor may make an Additional Use Grant, above, permitting limited production use.
21
+
22
+ Effective on the Change Date, or the fourth anniversary of the first publicly available distribution of a specific version of the Licensed Work under this License, whichever comes first, the Licensor hereby grants you rights under the terms of the Change License, and the rights granted in the paragraph above terminate.
23
+
24
+ If your use of the Licensed Work does not comply with the requirements currently in effect as described in this License, you must purchase a commercial license from the Licensor, its affiliated entities, or authorized resellers, or you must refrain from using the Licensed Work.
25
+
26
+ All copies of the original and modified Licensed Work, and derivative works of the Licensed Work, are subject to this License. This License applies separately for each version of the Licensed Work and the Change Date may vary for each version of the Licensed Work released by Licensor.
27
+
28
+ You must conspicuously display this License on each original or modified copy of the Licensed Work. If you receive the Licensed Work in original or modified form from a third party, the terms and conditions set forth in this License apply to your use of that work.
29
+
30
+ Any use of the Licensed Work in violation of this License will automatically terminate your rights under this License for the current and all other versions of the Licensed Work.
31
+
32
+ This License does not grant you any right in any trademark or logo of Licensor or its affiliates (provided that you may use a trademark or logo of Licensor as expressly required by this License).
33
+
34
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE.
35
+
36
+ MariaDB hereby grants you permission to use this License’s text to license your works, and to refer to it using the trademark “Business Source License”, as long as you comply with the Covenants of Licensor below.
37
+
38
+ ### Covenants of Licensor
39
+
40
+ In consideration of the right to use this License’s text and the “Business Source License” name and trademark, Licensor covenants to MariaDB, and to all other recipients of the licensed work to be provided by Licensor:
41
+
42
+ 1. To specify as the Change License the GPL Version 2.0 or any later version, or a license that is compatible with GPL Version 2.0 or a later version, where “compatible” means that software provided under the Change License can be included in a program with software provided under GPL Version 2.0 or a later version. Licensor may specify additional Change Licenses without limitation.
43
+
44
+ 2. To either: (a) specify an additional grant of rights to use that does not impose any additional restriction on the right granted in this License, as the Additional Use Grant; or (b) insert the text “None”.
45
+
46
+ 3. Not to modify this License in any other way.
47
+
48
+ ---
49
+
50
+ The Business Source License (this document, or the “License”) is not an Open Source license. However, the Licensed Work will eventually be made available under an Open Source License, as stated in this License.
package/dist/index.cjs CHANGED
@@ -31,11 +31,24 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  MysqlAdapter: () => MysqlAdapter,
34
+ closeAllMysqlClients: () => closeAllMysqlClients,
34
35
  mysqlAdapter: () => mysqlAdapter
35
36
  });
36
37
  module.exports = __toCommonJS(index_exports);
37
38
  var import_core = require("@dyrected/core");
38
39
  var import_promise = __toESM(require("mysql2/promise"), 1);
40
+ var MYSQL_CLIENT_CACHE_KEY = "__dyrectedMysqlClientCache";
41
+ function getSharedMysqlClientCache() {
42
+ const globalScope = globalThis;
43
+ if (!globalScope[MYSQL_CLIENT_CACHE_KEY]) {
44
+ globalScope[MYSQL_CLIENT_CACHE_KEY] = /* @__PURE__ */ new Map();
45
+ }
46
+ return globalScope[MYSQL_CLIENT_CACHE_KEY];
47
+ }
48
+ function getMysqlCacheKey(config) {
49
+ if (config.url) return config.url;
50
+ return `${config.host || "localhost"}:${config.port || 3306}:${config.database || ""}:${config.user || ""}`;
51
+ }
39
52
  function escapeMysqlIdentifier(identifier) {
40
53
  return `\`${identifier.replace(/`/g, "``")}\``;
41
54
  }
@@ -54,6 +67,9 @@ var MysqlAdapter = class {
54
67
  config;
55
68
  initPromise = null;
56
69
  inTransaction = false;
70
+ tableLocks = /* @__PURE__ */ new Map();
71
+ ensuredTables = /* @__PURE__ */ new Set();
72
+ tableColumnsCache = /* @__PURE__ */ new Map();
57
73
  constructor(config) {
58
74
  this.config = config;
59
75
  }
@@ -76,6 +92,25 @@ FIX INSTRUCTIONS:
76
92
  }
77
93
  throw err;
78
94
  }
95
+ async retryOperation(operation, maxRetries = 3, initialDelay = 1e3) {
96
+ let lastError;
97
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
98
+ try {
99
+ return await operation();
100
+ } catch (err) {
101
+ lastError = err;
102
+ const isNetworkOrTimeout = err.code === "ETIMEDOUT" || err.code === "ECONNREFUSED" || err.code === "PROTOCOL_CONNECTION_LOST" || err.message?.includes("ETIMEDOUT") || err.message?.includes("Connection lost");
103
+ if (attempt < maxRetries && isNetworkOrTimeout) {
104
+ const delay = initialDelay * Math.pow(2, attempt - 1);
105
+ console.warn(`[dyrected/db-mysql] Connection attempt ${attempt} failed (${err.code || err.message}), retrying in ${delay}ms...`);
106
+ await new Promise((resolve) => setTimeout(resolve, delay));
107
+ } else {
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ throw lastError;
113
+ }
79
114
  async ensureInitialized() {
80
115
  if (!this.initPromise) {
81
116
  this.initPromise = this.initialize();
@@ -83,59 +118,126 @@ FIX INSTRUCTIONS:
83
118
  await this.initPromise;
84
119
  }
85
120
  async initialize() {
86
- const config = this.config;
87
- let dbName = config.database;
88
- let serverConfig = null;
89
- if (config.url) {
90
- try {
91
- const parsed = new URL(config.url);
92
- dbName = parsed.pathname.replace(/^\//, "");
93
- const serverUrl = `${parsed.protocol}//${parsed.username}:${parsed.password}@${parsed.host}`;
94
- serverConfig = serverUrl;
95
- } catch (err) {
121
+ const cache = getSharedMysqlClientCache();
122
+ const cacheKey = getMysqlCacheKey(this.config);
123
+ const cached = cache.get(cacheKey);
124
+ if (cached?.pool) {
125
+ this.pool = cached.pool;
126
+ return;
127
+ }
128
+ if (cached?.initPromise) {
129
+ this.pool = await cached.initPromise;
130
+ return;
131
+ }
132
+ const initPromise = (async () => {
133
+ const config = this.config;
134
+ let dbName = config.database;
135
+ let serverConfig = null;
136
+ if (config.url) {
137
+ try {
138
+ const parsed = new URL(config.url);
139
+ dbName = parsed.pathname.replace(/^\//, "");
140
+ const serverUrl = `${parsed.protocol}//${parsed.username}:${parsed.password}@${parsed.host}`;
141
+ serverConfig = serverUrl;
142
+ } catch (err) {
143
+ }
144
+ } else {
145
+ serverConfig = {
146
+ host: config.host ?? "localhost",
147
+ port: config.port ?? 3306,
148
+ user: config.user,
149
+ password: config.password
150
+ };
96
151
  }
97
- } else {
98
- serverConfig = {
99
- host: config.host ?? "localhost",
100
- port: config.port ?? 3306,
101
- user: config.user,
102
- password: config.password
152
+ if (dbName && serverConfig) {
153
+ try {
154
+ await this.retryOperation(async () => {
155
+ const tempConn = await import_promise.default.createConnection(serverConfig);
156
+ try {
157
+ await tempConn.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
158
+ } finally {
159
+ await tempConn.end().catch(() => {
160
+ });
161
+ }
162
+ }, 3, 1e3);
163
+ console.log(`[dyrected/db-mysql] Database "${dbName}" checked/created successfully`);
164
+ } catch (err) {
165
+ console.warn(`[dyrected/db-mysql] Auto-creation of database "${dbName}" skipped/failed:`, err.message);
166
+ if (err.code === "EADDRNOTAVAIL" || err.message?.includes("EADDRNOTAVAIL")) {
167
+ this.handleConnectionError(err);
168
+ }
169
+ }
170
+ }
171
+ const defaultPoolOptions = {
172
+ enableKeepAlive: true,
173
+ keepAliveInitialDelay: 1e4,
174
+ connectTimeout: 2e4,
175
+ dateStrings: true,
176
+ waitForConnections: true,
177
+ connectionLimit: 10,
178
+ queueLimit: 0,
179
+ ...config.poolOptions
103
180
  };
104
- }
105
- if (dbName && serverConfig) {
181
+ let pool;
182
+ if (config.url) {
183
+ pool = import_promise.default.createPool({
184
+ uri: config.url,
185
+ ...defaultPoolOptions
186
+ });
187
+ } else {
188
+ pool = import_promise.default.createPool({
189
+ host: config.host ?? "localhost",
190
+ port: config.port ?? 3306,
191
+ user: config.user,
192
+ password: config.password,
193
+ database: config.database,
194
+ ...defaultPoolOptions
195
+ });
196
+ }
106
197
  try {
107
- const tempConn = await import_promise.default.createConnection(serverConfig);
108
- await tempConn.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
109
- await tempConn.end();
110
- console.log(`[dyrected/db-mysql] Database "${dbName}" checked/created successfully`);
198
+ await this.retryOperation(async () => {
199
+ await pool.query(`
200
+ CREATE TABLE IF NOT EXISTS dyrected_internal (
201
+ \`key\` VARCHAR(255) PRIMARY KEY,
202
+ value JSON NOT NULL
203
+ )
204
+ `);
205
+ await pool.query(`
206
+ CREATE TABLE IF NOT EXISTS _dyrected_ai_threads (
207
+ id VARCHAR(36) PRIMARY KEY,
208
+ project_id VARCHAR(255) NOT NULL,
209
+ user_id VARCHAR(255) NOT NULL,
210
+ title TEXT,
211
+ created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
212
+ updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
213
+ INDEX idx_ai_threads_user_project (user_id, project_id)
214
+ )
215
+ `);
216
+ await pool.query(`
217
+ CREATE TABLE IF NOT EXISTS _dyrected_ai_messages (
218
+ id VARCHAR(36) PRIMARY KEY,
219
+ thread_id VARCHAR(36) NOT NULL,
220
+ role VARCHAR(50) NOT NULL,
221
+ content LONGTEXT NOT NULL,
222
+ created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
223
+ metadata JSON,
224
+ INDEX idx_ai_messages_thread_id (thread_id),
225
+ CONSTRAINT fk_ai_messages_thread FOREIGN KEY (thread_id) REFERENCES _dyrected_ai_threads(id) ON DELETE CASCADE
226
+ )
227
+ `);
228
+ }, 3, 1e3);
111
229
  } catch (err) {
112
- console.warn(`[dyrected/db-mysql] Auto-creation of database "${dbName}" skipped/failed:`, err.message);
113
- if (err.code === "EADDRNOTAVAIL" || err.message?.includes("EADDRNOTAVAIL")) {
114
- this.handleConnectionError(err);
115
- }
230
+ this.handleConnectionError(err);
116
231
  }
117
- }
118
- if (config.url) {
119
- this.pool = import_promise.default.createPool(config.url);
120
- } else {
121
- this.pool = import_promise.default.createPool({
122
- host: config.host ?? "localhost",
123
- port: config.port ?? 3306,
124
- user: config.user,
125
- password: config.password,
126
- database: config.database,
127
- dateStrings: true
128
- });
129
- }
232
+ return pool;
233
+ })();
234
+ cache.set(cacheKey, { initPromise });
130
235
  try {
131
- await this.pool.query(`
132
- CREATE TABLE IF NOT EXISTS dyrected_internal (
133
- \`key\` VARCHAR(255) PRIMARY KEY,
134
- value JSON NOT NULL
135
- )
136
- `);
137
- } catch (err) {
138
- this.handleConnectionError(err);
236
+ this.pool = await initPromise;
237
+ cache.set(cacheKey, { pool: this.pool });
238
+ } catch (error) {
239
+ cache.delete(cacheKey);
240
+ throw error;
139
241
  }
140
242
  }
141
243
  async query(sql, params) {
@@ -155,28 +257,92 @@ FIX INSTRUCTIONS:
155
257
  }
156
258
  }
157
259
  getTableName(slug) {
158
- return `collection_${slug}`;
260
+ if (slug.includes(".")) {
261
+ const parts = slug.split(".");
262
+ return parts[parts.length - 1].replace(/`/g, "");
263
+ }
264
+ return slug.startsWith("collection_") ? slug : `collection_${slug}`;
159
265
  }
160
- async ensureTable(slug, fields = []) {
161
- const tableName = this.getTableName(slug);
162
- await this.query(`
163
- CREATE TABLE IF NOT EXISTS \`${tableName}\` (
164
- id VARCHAR(36) PRIMARY KEY,
165
- data JSON NOT NULL,
166
- created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
167
- updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
168
- )
169
- `);
266
+ async getTableColumns(tableName) {
267
+ const cached = this.tableColumnsCache.get(tableName);
268
+ if (cached) return cached;
170
269
  const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
171
270
  const existingCols = cols.map((c) => c.Field);
172
- for (const field of fields) {
173
- if (field.promoted && !existingCols.includes(field.name)) {
174
- console.log(`[dyrected/mysql] Promoting field "${field.name}" to column in ${tableName}`);
175
- let sqlType = "TEXT";
176
- if (field.type === "number") sqlType = "DECIMAL(19,4)";
177
- if (field.type === "boolean") sqlType = "TINYINT(1)";
178
- await this.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${field.name}\` ${sqlType}`);
271
+ this.tableColumnsCache.set(tableName, existingCols);
272
+ return existingCols;
273
+ }
274
+ async ensureTable(slug, fields = []) {
275
+ if (slug === "_dyrected_ai_threads" || slug === "_dyrected_ai_messages") {
276
+ return;
277
+ }
278
+ if (fields.length === 0 && this.ensuredTables.has(slug)) {
279
+ return;
280
+ }
281
+ if (this.tableLocks.has(slug)) {
282
+ await this.tableLocks.get(slug);
283
+ if (fields.length === 0 && this.ensuredTables.has(slug)) {
284
+ return;
285
+ }
286
+ }
287
+ let resolveLock;
288
+ let rejectLock;
289
+ const lockPromise = new Promise((resolve, reject) => {
290
+ resolveLock = resolve;
291
+ rejectLock = reject;
292
+ });
293
+ this.tableLocks.set(slug, lockPromise);
294
+ try {
295
+ const tableName = this.getTableName(slug);
296
+ await this.query(`
297
+ CREATE TABLE IF NOT EXISTS \`${tableName}\` (
298
+ id VARCHAR(36) PRIMARY KEY,
299
+ data JSON NOT NULL,
300
+ created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
301
+ updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
302
+ )
303
+ `);
304
+ const existingCols = await this.getTableColumns(tableName);
305
+ for (const field of fields) {
306
+ if (field.promoted && !existingCols.includes(field.name)) {
307
+ console.log(`[dyrected/mysql] Promoting field "${field.name}" to column in ${tableName}`);
308
+ let sqlType = "TEXT";
309
+ if (field.type === "number") sqlType = "DECIMAL(19,4)";
310
+ if (field.type === "boolean") sqlType = "TINYINT(1)";
311
+ if (field.type === "date" || field.type === "datetime") sqlType = "DATETIME(3)";
312
+ try {
313
+ await this.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${field.name}\` ${sqlType}`);
314
+ existingCols.push(field.name);
315
+ this.tableColumnsCache.set(tableName, existingCols);
316
+ } catch (err) {
317
+ if (err.code === "ER_DUP_FIELDNAME" || err.errno === 1060 || err.message?.includes("Duplicate column name")) {
318
+ } else {
319
+ throw err;
320
+ }
321
+ }
322
+ const escapedField = field.name.replace(/`/g, "``");
323
+ let castExpr = `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}'))`;
324
+ if (field.type === "number") {
325
+ castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) REGEXP '^-?[0-9]+(\\\\.[0-9]+)?([eE][+-]?[0-9]+)?$', CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) AS DECIMAL(19,4)), NULL)`;
326
+ } else if (field.type === "boolean") {
327
+ castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) IN ('true', '1'), 1, 0)`;
328
+ } else if (field.type === "date" || field.type === "datetime") {
329
+ castExpr = `STR_TO_DATE(LEFT(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')), 23), '%Y-%m-%d %H:%i:%s.%f')`;
330
+ }
331
+ try {
332
+ await this.query(
333
+ `UPDATE \`${tableName}\` SET \`${escapedField}\` = ${castExpr} WHERE \`${escapedField}\` IS NULL AND JSON_CONTAINS_PATH(data, 'one', '$.${field.name}')`
334
+ );
335
+ } catch {
336
+ }
337
+ }
179
338
  }
339
+ this.ensuredTables.add(slug);
340
+ resolveLock();
341
+ } catch (err) {
342
+ rejectLock(err);
343
+ throw err;
344
+ } finally {
345
+ this.tableLocks.delete(slug);
180
346
  }
181
347
  }
182
348
  async find(args) {
@@ -185,8 +351,7 @@ FIX INSTRUCTIONS:
185
351
  const limit = args.limit ?? 10;
186
352
  const page = args.page ?? 1;
187
353
  const offset = (page - 1) * limit;
188
- const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
189
- const existingCols = cols.map((c) => c.Field);
354
+ const existingCols = await this.getTableColumns(tableName);
190
355
  let whereSql = "";
191
356
  let whereParams = [];
192
357
  if (args.where && Object.keys(args.where).length > 0) {
@@ -249,8 +414,7 @@ FIX INSTRUCTIONS:
249
414
  async create(params) {
250
415
  if (!this.inTransaction) await this.ensureTable(params.collection);
251
416
  const tableName = this.getTableName(params.collection);
252
- const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
253
- const existingCols = cols.map((c) => c.Field);
417
+ const existingCols = await this.getTableColumns(tableName);
254
418
  const id = params.data.id ?? Math.random().toString(36).substring(7);
255
419
  const now = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
256
420
  const data = { ...params.data };
@@ -273,8 +437,7 @@ FIX INSTRUCTIONS:
273
437
  async update(params) {
274
438
  if (!this.inTransaction) await this.ensureTable(params.collection);
275
439
  const tableName = this.getTableName(params.collection);
276
- const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
277
- const existingCols = cols.map((c) => c.Field);
440
+ const existingCols = await this.getTableColumns(tableName);
278
441
  const existing = await this.findOne({ collection: params.collection, id: params.id });
279
442
  const now = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
280
443
  const merged = { ...existing ?? {}, ...params.data };
@@ -349,8 +512,7 @@ FIX INSTRUCTIONS:
349
512
  }
350
513
  const tableName = this.getTableName(args.collection);
351
514
  if (!this.inTransaction) await this.ensureTable(args.collection);
352
- const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
353
- const existingCols = cols.map((c) => c.Field);
515
+ const existingCols = await this.getTableColumns(tableName);
354
516
  const toFieldExpr = (field) => {
355
517
  if (field === "createdAt") return "`created_at`";
356
518
  if (field === "updatedAt") return "`updated_at`";
@@ -443,6 +605,11 @@ FIX INSTRUCTIONS:
443
605
  return result;
444
606
  }
445
607
  async disconnect() {
608
+ const cache = getSharedMysqlClientCache();
609
+ const cacheKey = getMysqlCacheKey(this.config);
610
+ cache.delete(cacheKey);
611
+ this.ensuredTables.clear();
612
+ this.tableColumnsCache.clear();
446
613
  if (this.initPromise) {
447
614
  try {
448
615
  await this.initPromise;
@@ -457,9 +624,25 @@ FIX INSTRUCTIONS:
457
624
  this.pool = null;
458
625
  }
459
626
  };
627
+ async function closeAllMysqlClients() {
628
+ const cache = getSharedMysqlClientCache();
629
+ const entries = Array.from(cache.values());
630
+ cache.clear();
631
+ for (const entry of entries) {
632
+ try {
633
+ const pool = entry.pool || await entry.initPromise;
634
+ if (pool && typeof pool.end === "function") {
635
+ await pool.end().catch(() => {
636
+ });
637
+ }
638
+ } catch {
639
+ }
640
+ }
641
+ }
460
642
  var mysqlAdapter = (config) => new MysqlAdapter(config);
461
643
  // Annotate the CommonJS export names for ESM import in node:
462
644
  0 && (module.exports = {
463
645
  MysqlAdapter,
646
+ closeAllMysqlClients,
464
647
  mysqlAdapter
465
648
  });
package/dist/index.d.cts CHANGED
@@ -9,19 +9,26 @@ interface MysqlAdapterConfig {
9
9
  user?: string;
10
10
  password?: string;
11
11
  database?: string;
12
+ /** Optional pool options passed directly to mysql2 createPool */
13
+ poolOptions?: Record<string, any>;
12
14
  }
13
15
  declare class MysqlAdapter implements DatabaseAdapter {
14
16
  private pool;
15
17
  private config;
16
18
  private initPromise;
17
19
  private inTransaction;
20
+ private tableLocks;
21
+ private ensuredTables;
22
+ private tableColumnsCache;
18
23
  constructor(config: MysqlAdapterConfig);
19
24
  private handleConnectionError;
25
+ private retryOperation;
20
26
  private ensureInitialized;
21
27
  private initialize;
22
28
  private query;
23
29
  execute(sql: string, params?: any[]): Promise<any>;
24
30
  private getTableName;
31
+ private getTableColumns;
25
32
  private ensureTable;
26
33
  find(args: {
27
34
  collection: string;
@@ -65,6 +72,7 @@ declare class MysqlAdapter implements DatabaseAdapter {
65
72
  }): Promise<Record<string, any>>;
66
73
  disconnect(): Promise<void>;
67
74
  }
75
+ declare function closeAllMysqlClients(): Promise<void>;
68
76
  declare const mysqlAdapter: (config: MysqlAdapterConfig) => MysqlAdapter;
69
77
 
70
- export { MysqlAdapter, type MysqlAdapterConfig, mysqlAdapter };
78
+ export { MysqlAdapter, type MysqlAdapterConfig, closeAllMysqlClients, mysqlAdapter };
package/dist/index.d.ts CHANGED
@@ -9,19 +9,26 @@ interface MysqlAdapterConfig {
9
9
  user?: string;
10
10
  password?: string;
11
11
  database?: string;
12
+ /** Optional pool options passed directly to mysql2 createPool */
13
+ poolOptions?: Record<string, any>;
12
14
  }
13
15
  declare class MysqlAdapter implements DatabaseAdapter {
14
16
  private pool;
15
17
  private config;
16
18
  private initPromise;
17
19
  private inTransaction;
20
+ private tableLocks;
21
+ private ensuredTables;
22
+ private tableColumnsCache;
18
23
  constructor(config: MysqlAdapterConfig);
19
24
  private handleConnectionError;
25
+ private retryOperation;
20
26
  private ensureInitialized;
21
27
  private initialize;
22
28
  private query;
23
29
  execute(sql: string, params?: any[]): Promise<any>;
24
30
  private getTableName;
31
+ private getTableColumns;
25
32
  private ensureTable;
26
33
  find(args: {
27
34
  collection: string;
@@ -65,6 +72,7 @@ declare class MysqlAdapter implements DatabaseAdapter {
65
72
  }): Promise<Record<string, any>>;
66
73
  disconnect(): Promise<void>;
67
74
  }
75
+ declare function closeAllMysqlClients(): Promise<void>;
68
76
  declare const mysqlAdapter: (config: MysqlAdapterConfig) => MysqlAdapter;
69
77
 
70
- export { MysqlAdapter, type MysqlAdapterConfig, mysqlAdapter };
78
+ export { MysqlAdapter, type MysqlAdapterConfig, closeAllMysqlClients, mysqlAdapter };
package/dist/index.js CHANGED
@@ -1,6 +1,18 @@
1
1
  // src/index.ts
2
2
  import { parseSort, parseSqlWhere } from "@dyrected/core";
3
3
  import mysql from "mysql2/promise";
4
+ var MYSQL_CLIENT_CACHE_KEY = "__dyrectedMysqlClientCache";
5
+ function getSharedMysqlClientCache() {
6
+ const globalScope = globalThis;
7
+ if (!globalScope[MYSQL_CLIENT_CACHE_KEY]) {
8
+ globalScope[MYSQL_CLIENT_CACHE_KEY] = /* @__PURE__ */ new Map();
9
+ }
10
+ return globalScope[MYSQL_CLIENT_CACHE_KEY];
11
+ }
12
+ function getMysqlCacheKey(config) {
13
+ if (config.url) return config.url;
14
+ return `${config.host || "localhost"}:${config.port || 3306}:${config.database || ""}:${config.user || ""}`;
15
+ }
4
16
  function escapeMysqlIdentifier(identifier) {
5
17
  return `\`${identifier.replace(/`/g, "``")}\``;
6
18
  }
@@ -19,6 +31,9 @@ var MysqlAdapter = class {
19
31
  config;
20
32
  initPromise = null;
21
33
  inTransaction = false;
34
+ tableLocks = /* @__PURE__ */ new Map();
35
+ ensuredTables = /* @__PURE__ */ new Set();
36
+ tableColumnsCache = /* @__PURE__ */ new Map();
22
37
  constructor(config) {
23
38
  this.config = config;
24
39
  }
@@ -41,6 +56,25 @@ FIX INSTRUCTIONS:
41
56
  }
42
57
  throw err;
43
58
  }
59
+ async retryOperation(operation, maxRetries = 3, initialDelay = 1e3) {
60
+ let lastError;
61
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
62
+ try {
63
+ return await operation();
64
+ } catch (err) {
65
+ lastError = err;
66
+ const isNetworkOrTimeout = err.code === "ETIMEDOUT" || err.code === "ECONNREFUSED" || err.code === "PROTOCOL_CONNECTION_LOST" || err.message?.includes("ETIMEDOUT") || err.message?.includes("Connection lost");
67
+ if (attempt < maxRetries && isNetworkOrTimeout) {
68
+ const delay = initialDelay * Math.pow(2, attempt - 1);
69
+ console.warn(`[dyrected/db-mysql] Connection attempt ${attempt} failed (${err.code || err.message}), retrying in ${delay}ms...`);
70
+ await new Promise((resolve) => setTimeout(resolve, delay));
71
+ } else {
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ throw lastError;
77
+ }
44
78
  async ensureInitialized() {
45
79
  if (!this.initPromise) {
46
80
  this.initPromise = this.initialize();
@@ -48,59 +82,126 @@ FIX INSTRUCTIONS:
48
82
  await this.initPromise;
49
83
  }
50
84
  async initialize() {
51
- const config = this.config;
52
- let dbName = config.database;
53
- let serverConfig = null;
54
- if (config.url) {
55
- try {
56
- const parsed = new URL(config.url);
57
- dbName = parsed.pathname.replace(/^\//, "");
58
- const serverUrl = `${parsed.protocol}//${parsed.username}:${parsed.password}@${parsed.host}`;
59
- serverConfig = serverUrl;
60
- } catch (err) {
85
+ const cache = getSharedMysqlClientCache();
86
+ const cacheKey = getMysqlCacheKey(this.config);
87
+ const cached = cache.get(cacheKey);
88
+ if (cached?.pool) {
89
+ this.pool = cached.pool;
90
+ return;
91
+ }
92
+ if (cached?.initPromise) {
93
+ this.pool = await cached.initPromise;
94
+ return;
95
+ }
96
+ const initPromise = (async () => {
97
+ const config = this.config;
98
+ let dbName = config.database;
99
+ let serverConfig = null;
100
+ if (config.url) {
101
+ try {
102
+ const parsed = new URL(config.url);
103
+ dbName = parsed.pathname.replace(/^\//, "");
104
+ const serverUrl = `${parsed.protocol}//${parsed.username}:${parsed.password}@${parsed.host}`;
105
+ serverConfig = serverUrl;
106
+ } catch (err) {
107
+ }
108
+ } else {
109
+ serverConfig = {
110
+ host: config.host ?? "localhost",
111
+ port: config.port ?? 3306,
112
+ user: config.user,
113
+ password: config.password
114
+ };
61
115
  }
62
- } else {
63
- serverConfig = {
64
- host: config.host ?? "localhost",
65
- port: config.port ?? 3306,
66
- user: config.user,
67
- password: config.password
116
+ if (dbName && serverConfig) {
117
+ try {
118
+ await this.retryOperation(async () => {
119
+ const tempConn = await mysql.createConnection(serverConfig);
120
+ try {
121
+ await tempConn.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
122
+ } finally {
123
+ await tempConn.end().catch(() => {
124
+ });
125
+ }
126
+ }, 3, 1e3);
127
+ console.log(`[dyrected/db-mysql] Database "${dbName}" checked/created successfully`);
128
+ } catch (err) {
129
+ console.warn(`[dyrected/db-mysql] Auto-creation of database "${dbName}" skipped/failed:`, err.message);
130
+ if (err.code === "EADDRNOTAVAIL" || err.message?.includes("EADDRNOTAVAIL")) {
131
+ this.handleConnectionError(err);
132
+ }
133
+ }
134
+ }
135
+ const defaultPoolOptions = {
136
+ enableKeepAlive: true,
137
+ keepAliveInitialDelay: 1e4,
138
+ connectTimeout: 2e4,
139
+ dateStrings: true,
140
+ waitForConnections: true,
141
+ connectionLimit: 10,
142
+ queueLimit: 0,
143
+ ...config.poolOptions
68
144
  };
69
- }
70
- if (dbName && serverConfig) {
145
+ let pool;
146
+ if (config.url) {
147
+ pool = mysql.createPool({
148
+ uri: config.url,
149
+ ...defaultPoolOptions
150
+ });
151
+ } else {
152
+ pool = mysql.createPool({
153
+ host: config.host ?? "localhost",
154
+ port: config.port ?? 3306,
155
+ user: config.user,
156
+ password: config.password,
157
+ database: config.database,
158
+ ...defaultPoolOptions
159
+ });
160
+ }
71
161
  try {
72
- const tempConn = await mysql.createConnection(serverConfig);
73
- await tempConn.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
74
- await tempConn.end();
75
- console.log(`[dyrected/db-mysql] Database "${dbName}" checked/created successfully`);
162
+ await this.retryOperation(async () => {
163
+ await pool.query(`
164
+ CREATE TABLE IF NOT EXISTS dyrected_internal (
165
+ \`key\` VARCHAR(255) PRIMARY KEY,
166
+ value JSON NOT NULL
167
+ )
168
+ `);
169
+ await pool.query(`
170
+ CREATE TABLE IF NOT EXISTS _dyrected_ai_threads (
171
+ id VARCHAR(36) PRIMARY KEY,
172
+ project_id VARCHAR(255) NOT NULL,
173
+ user_id VARCHAR(255) NOT NULL,
174
+ title TEXT,
175
+ created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
176
+ updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
177
+ INDEX idx_ai_threads_user_project (user_id, project_id)
178
+ )
179
+ `);
180
+ await pool.query(`
181
+ CREATE TABLE IF NOT EXISTS _dyrected_ai_messages (
182
+ id VARCHAR(36) PRIMARY KEY,
183
+ thread_id VARCHAR(36) NOT NULL,
184
+ role VARCHAR(50) NOT NULL,
185
+ content LONGTEXT NOT NULL,
186
+ created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
187
+ metadata JSON,
188
+ INDEX idx_ai_messages_thread_id (thread_id),
189
+ CONSTRAINT fk_ai_messages_thread FOREIGN KEY (thread_id) REFERENCES _dyrected_ai_threads(id) ON DELETE CASCADE
190
+ )
191
+ `);
192
+ }, 3, 1e3);
76
193
  } catch (err) {
77
- console.warn(`[dyrected/db-mysql] Auto-creation of database "${dbName}" skipped/failed:`, err.message);
78
- if (err.code === "EADDRNOTAVAIL" || err.message?.includes("EADDRNOTAVAIL")) {
79
- this.handleConnectionError(err);
80
- }
194
+ this.handleConnectionError(err);
81
195
  }
82
- }
83
- if (config.url) {
84
- this.pool = mysql.createPool(config.url);
85
- } else {
86
- this.pool = mysql.createPool({
87
- host: config.host ?? "localhost",
88
- port: config.port ?? 3306,
89
- user: config.user,
90
- password: config.password,
91
- database: config.database,
92
- dateStrings: true
93
- });
94
- }
196
+ return pool;
197
+ })();
198
+ cache.set(cacheKey, { initPromise });
95
199
  try {
96
- await this.pool.query(`
97
- CREATE TABLE IF NOT EXISTS dyrected_internal (
98
- \`key\` VARCHAR(255) PRIMARY KEY,
99
- value JSON NOT NULL
100
- )
101
- `);
102
- } catch (err) {
103
- this.handleConnectionError(err);
200
+ this.pool = await initPromise;
201
+ cache.set(cacheKey, { pool: this.pool });
202
+ } catch (error) {
203
+ cache.delete(cacheKey);
204
+ throw error;
104
205
  }
105
206
  }
106
207
  async query(sql, params) {
@@ -120,28 +221,92 @@ FIX INSTRUCTIONS:
120
221
  }
121
222
  }
122
223
  getTableName(slug) {
123
- return `collection_${slug}`;
224
+ if (slug.includes(".")) {
225
+ const parts = slug.split(".");
226
+ return parts[parts.length - 1].replace(/`/g, "");
227
+ }
228
+ return slug.startsWith("collection_") ? slug : `collection_${slug}`;
124
229
  }
125
- async ensureTable(slug, fields = []) {
126
- const tableName = this.getTableName(slug);
127
- await this.query(`
128
- CREATE TABLE IF NOT EXISTS \`${tableName}\` (
129
- id VARCHAR(36) PRIMARY KEY,
130
- data JSON NOT NULL,
131
- created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
132
- updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
133
- )
134
- `);
230
+ async getTableColumns(tableName) {
231
+ const cached = this.tableColumnsCache.get(tableName);
232
+ if (cached) return cached;
135
233
  const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
136
234
  const existingCols = cols.map((c) => c.Field);
137
- for (const field of fields) {
138
- if (field.promoted && !existingCols.includes(field.name)) {
139
- console.log(`[dyrected/mysql] Promoting field "${field.name}" to column in ${tableName}`);
140
- let sqlType = "TEXT";
141
- if (field.type === "number") sqlType = "DECIMAL(19,4)";
142
- if (field.type === "boolean") sqlType = "TINYINT(1)";
143
- await this.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${field.name}\` ${sqlType}`);
235
+ this.tableColumnsCache.set(tableName, existingCols);
236
+ return existingCols;
237
+ }
238
+ async ensureTable(slug, fields = []) {
239
+ if (slug === "_dyrected_ai_threads" || slug === "_dyrected_ai_messages") {
240
+ return;
241
+ }
242
+ if (fields.length === 0 && this.ensuredTables.has(slug)) {
243
+ return;
244
+ }
245
+ if (this.tableLocks.has(slug)) {
246
+ await this.tableLocks.get(slug);
247
+ if (fields.length === 0 && this.ensuredTables.has(slug)) {
248
+ return;
249
+ }
250
+ }
251
+ let resolveLock;
252
+ let rejectLock;
253
+ const lockPromise = new Promise((resolve, reject) => {
254
+ resolveLock = resolve;
255
+ rejectLock = reject;
256
+ });
257
+ this.tableLocks.set(slug, lockPromise);
258
+ try {
259
+ const tableName = this.getTableName(slug);
260
+ await this.query(`
261
+ CREATE TABLE IF NOT EXISTS \`${tableName}\` (
262
+ id VARCHAR(36) PRIMARY KEY,
263
+ data JSON NOT NULL,
264
+ created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
265
+ updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
266
+ )
267
+ `);
268
+ const existingCols = await this.getTableColumns(tableName);
269
+ for (const field of fields) {
270
+ if (field.promoted && !existingCols.includes(field.name)) {
271
+ console.log(`[dyrected/mysql] Promoting field "${field.name}" to column in ${tableName}`);
272
+ let sqlType = "TEXT";
273
+ if (field.type === "number") sqlType = "DECIMAL(19,4)";
274
+ if (field.type === "boolean") sqlType = "TINYINT(1)";
275
+ if (field.type === "date" || field.type === "datetime") sqlType = "DATETIME(3)";
276
+ try {
277
+ await this.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${field.name}\` ${sqlType}`);
278
+ existingCols.push(field.name);
279
+ this.tableColumnsCache.set(tableName, existingCols);
280
+ } catch (err) {
281
+ if (err.code === "ER_DUP_FIELDNAME" || err.errno === 1060 || err.message?.includes("Duplicate column name")) {
282
+ } else {
283
+ throw err;
284
+ }
285
+ }
286
+ const escapedField = field.name.replace(/`/g, "``");
287
+ let castExpr = `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}'))`;
288
+ if (field.type === "number") {
289
+ castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) REGEXP '^-?[0-9]+(\\\\.[0-9]+)?([eE][+-]?[0-9]+)?$', CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) AS DECIMAL(19,4)), NULL)`;
290
+ } else if (field.type === "boolean") {
291
+ castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) IN ('true', '1'), 1, 0)`;
292
+ } else if (field.type === "date" || field.type === "datetime") {
293
+ castExpr = `STR_TO_DATE(LEFT(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')), 23), '%Y-%m-%d %H:%i:%s.%f')`;
294
+ }
295
+ try {
296
+ await this.query(
297
+ `UPDATE \`${tableName}\` SET \`${escapedField}\` = ${castExpr} WHERE \`${escapedField}\` IS NULL AND JSON_CONTAINS_PATH(data, 'one', '$.${field.name}')`
298
+ );
299
+ } catch {
300
+ }
301
+ }
144
302
  }
303
+ this.ensuredTables.add(slug);
304
+ resolveLock();
305
+ } catch (err) {
306
+ rejectLock(err);
307
+ throw err;
308
+ } finally {
309
+ this.tableLocks.delete(slug);
145
310
  }
146
311
  }
147
312
  async find(args) {
@@ -150,8 +315,7 @@ FIX INSTRUCTIONS:
150
315
  const limit = args.limit ?? 10;
151
316
  const page = args.page ?? 1;
152
317
  const offset = (page - 1) * limit;
153
- const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
154
- const existingCols = cols.map((c) => c.Field);
318
+ const existingCols = await this.getTableColumns(tableName);
155
319
  let whereSql = "";
156
320
  let whereParams = [];
157
321
  if (args.where && Object.keys(args.where).length > 0) {
@@ -214,8 +378,7 @@ FIX INSTRUCTIONS:
214
378
  async create(params) {
215
379
  if (!this.inTransaction) await this.ensureTable(params.collection);
216
380
  const tableName = this.getTableName(params.collection);
217
- const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
218
- const existingCols = cols.map((c) => c.Field);
381
+ const existingCols = await this.getTableColumns(tableName);
219
382
  const id = params.data.id ?? Math.random().toString(36).substring(7);
220
383
  const now = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
221
384
  const data = { ...params.data };
@@ -238,8 +401,7 @@ FIX INSTRUCTIONS:
238
401
  async update(params) {
239
402
  if (!this.inTransaction) await this.ensureTable(params.collection);
240
403
  const tableName = this.getTableName(params.collection);
241
- const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
242
- const existingCols = cols.map((c) => c.Field);
404
+ const existingCols = await this.getTableColumns(tableName);
243
405
  const existing = await this.findOne({ collection: params.collection, id: params.id });
244
406
  const now = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
245
407
  const merged = { ...existing ?? {}, ...params.data };
@@ -314,8 +476,7 @@ FIX INSTRUCTIONS:
314
476
  }
315
477
  const tableName = this.getTableName(args.collection);
316
478
  if (!this.inTransaction) await this.ensureTable(args.collection);
317
- const [cols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\``);
318
- const existingCols = cols.map((c) => c.Field);
479
+ const existingCols = await this.getTableColumns(tableName);
319
480
  const toFieldExpr = (field) => {
320
481
  if (field === "createdAt") return "`created_at`";
321
482
  if (field === "updatedAt") return "`updated_at`";
@@ -408,6 +569,11 @@ FIX INSTRUCTIONS:
408
569
  return result;
409
570
  }
410
571
  async disconnect() {
572
+ const cache = getSharedMysqlClientCache();
573
+ const cacheKey = getMysqlCacheKey(this.config);
574
+ cache.delete(cacheKey);
575
+ this.ensuredTables.clear();
576
+ this.tableColumnsCache.clear();
411
577
  if (this.initPromise) {
412
578
  try {
413
579
  await this.initPromise;
@@ -422,8 +588,24 @@ FIX INSTRUCTIONS:
422
588
  this.pool = null;
423
589
  }
424
590
  };
591
+ async function closeAllMysqlClients() {
592
+ const cache = getSharedMysqlClientCache();
593
+ const entries = Array.from(cache.values());
594
+ cache.clear();
595
+ for (const entry of entries) {
596
+ try {
597
+ const pool = entry.pool || await entry.initPromise;
598
+ if (pool && typeof pool.end === "function") {
599
+ await pool.end().catch(() => {
600
+ });
601
+ }
602
+ } catch {
603
+ }
604
+ }
605
+ }
425
606
  var mysqlAdapter = (config) => new MysqlAdapter(config);
426
607
  export {
427
608
  MysqlAdapter,
609
+ closeAllMysqlClients,
428
610
  mysqlAdapter
429
611
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyrected/db-mysql",
3
- "version": "2.13.0",
3
+ "version": "2.13.3",
4
4
  "description": "MySQL adapter for Dyrected CMS",
5
5
  "type": "module",
6
6
  "files": [
@@ -16,13 +16,9 @@
16
16
  "require": "./dist/index.cjs"
17
17
  }
18
18
  },
19
- "scripts": {
20
- "build": "tsup src/index.ts --format esm,cjs --dts --clean",
21
- "dev": "tsup src/index.ts --format esm,cjs --dts --watch"
22
- },
23
19
  "dependencies": {
24
- "@dyrected/core": "workspace:^",
25
- "mysql2": "^3.22.3"
20
+ "mysql2": "^3.22.3",
21
+ "@dyrected/core": "^2.13.3"
26
22
  },
27
23
  "devDependencies": {
28
24
  "tsup": "^8.0.0",
@@ -36,5 +32,9 @@
36
32
  },
37
33
  "publishConfig": {
38
34
  "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
38
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch"
39
39
  }
40
40
  }