@dyrected/db-mysql 2.14.0 → 2.16.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.
package/dist/index.cjs CHANGED
@@ -37,6 +37,22 @@ __export(index_exports, {
37
37
  module.exports = __toCommonJS(index_exports);
38
38
  var import_core = require("@dyrected/core");
39
39
  var import_promise = __toESM(require("mysql2/promise"), 1);
40
+ function parseMysqlDuplicateKey(message) {
41
+ const match = message.match(/Duplicate entry '(.*)' for key '(?:[^.]*\.)?(.*)'/);
42
+ if (!match) return {};
43
+ const [, value, keyName] = match;
44
+ let field = keyName;
45
+ if (keyName === "PRIMARY") {
46
+ field = "id";
47
+ } else if (keyName.startsWith("uniq_") || keyName.startsWith("idx_")) {
48
+ const parts = keyName.split("_");
49
+ field = parts[parts.length - 1];
50
+ }
51
+ return { field, value };
52
+ }
53
+ function isNumericOp(val) {
54
+ return val !== null && typeof val === "object" && !Array.isArray(val) && ("increment" in val || "decrement" in val);
55
+ }
40
56
  var MYSQL_CLIENT_CACHE_KEY = "__dyrectedMysqlClientCache";
41
57
  function getSharedMysqlClientCache() {
42
58
  const globalScope = globalThis;
@@ -70,6 +86,7 @@ var MysqlAdapter = class {
70
86
  tableLocks = /* @__PURE__ */ new Map();
71
87
  ensuredTables = /* @__PURE__ */ new Set();
72
88
  tableColumnsCache = /* @__PURE__ */ new Map();
89
+ collectionConfigs = /* @__PURE__ */ new Map();
73
90
  constructor(config) {
74
91
  this.config = config;
75
92
  }
@@ -204,7 +221,7 @@ FIX INSTRUCTIONS:
204
221
  `);
205
222
  await pool.query(`
206
223
  CREATE TABLE IF NOT EXISTS _dyrected_ai_threads (
207
- id VARCHAR(36) PRIMARY KEY,
224
+ id VARCHAR(191) PRIMARY KEY,
208
225
  project_id VARCHAR(255) NOT NULL,
209
226
  user_id VARCHAR(255) NOT NULL,
210
227
  title TEXT,
@@ -215,8 +232,8 @@ FIX INSTRUCTIONS:
215
232
  `);
216
233
  await pool.query(`
217
234
  CREATE TABLE IF NOT EXISTS _dyrected_ai_messages (
218
- id VARCHAR(36) PRIMARY KEY,
219
- thread_id VARCHAR(36) NOT NULL,
235
+ id VARCHAR(191) PRIMARY KEY,
236
+ thread_id VARCHAR(191) NOT NULL,
220
237
  role VARCHAR(50) NOT NULL,
221
238
  content LONGTEXT NOT NULL,
222
239
  created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
@@ -240,12 +257,22 @@ FIX INSTRUCTIONS:
240
257
  throw error;
241
258
  }
242
259
  }
260
+ handleDatabaseError(err) {
261
+ if (err && (err.errno === 1062 || err.code === "ER_DUP_ENTRY" || err.message?.includes("Duplicate entry"))) {
262
+ const { field, value } = parseMysqlDuplicateKey(err.message || "");
263
+ throw new import_core.DuplicateKeyError(
264
+ `Duplicate entry${value ? ` '${value}'` : ""} for unique key${field ? ` '${field}'` : ""}`,
265
+ { field, value }
266
+ );
267
+ }
268
+ this.handleConnectionError(err);
269
+ }
243
270
  async query(sql, params) {
244
271
  await this.ensureInitialized();
245
272
  try {
246
273
  return await this.pool.query(sql, params);
247
274
  } catch (err) {
248
- this.handleConnectionError(err);
275
+ this.handleDatabaseError(err);
249
276
  }
250
277
  }
251
278
  async execute(sql, params) {
@@ -253,7 +280,7 @@ FIX INSTRUCTIONS:
253
280
  try {
254
281
  return await this.pool.execute(sql, params);
255
282
  } catch (err) {
256
- this.handleConnectionError(err);
283
+ this.handleDatabaseError(err);
257
284
  }
258
285
  }
259
286
  getTableName(slug) {
@@ -271,7 +298,7 @@ FIX INSTRUCTIONS:
271
298
  this.tableColumnsCache.set(tableName, existingCols);
272
299
  return existingCols;
273
300
  }
274
- async ensureTable(slug, fields = []) {
301
+ async ensureTable(slug, fields = [], indexes = []) {
275
302
  if (slug === "_dyrected_ai_threads" || slug === "_dyrected_ai_messages") {
276
303
  return;
277
304
  }
@@ -295,20 +322,39 @@ FIX INSTRUCTIONS:
295
322
  const tableName = this.getTableName(slug);
296
323
  await this.query(`
297
324
  CREATE TABLE IF NOT EXISTS \`${tableName}\` (
298
- id VARCHAR(36) PRIMARY KEY,
325
+ id VARCHAR(191) PRIMARY KEY,
299
326
  data JSON NOT NULL,
300
327
  created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
301
328
  updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
302
329
  )
303
330
  `);
304
331
  const existingCols = await this.getTableColumns(tableName);
332
+ try {
333
+ const [idCols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\` WHERE Field = 'id'`);
334
+ const idCol = idCols?.[0];
335
+ if (idCol && typeof idCol.Type === "string" && idCol.Type.toLowerCase().includes("varchar(36)")) {
336
+ await this.query(`ALTER TABLE \`${tableName}\` MODIFY COLUMN id VARCHAR(191) NOT NULL`);
337
+ }
338
+ } catch {
339
+ }
340
+ const indexedFields = /* @__PURE__ */ new Set();
341
+ if (Array.isArray(indexes)) {
342
+ for (const idx of indexes) {
343
+ if (Array.isArray(idx.fields)) {
344
+ for (const f of idx.fields) indexedFields.add(f);
345
+ }
346
+ }
347
+ }
305
348
  for (const field of fields) {
306
- if (field.promoted && !existingCols.includes(field.name)) {
349
+ const shouldPromote = field.promoted || field.unique || indexedFields.has(field.name);
350
+ if (shouldPromote && !existingCols.includes(field.name)) {
307
351
  console.log(`[dyrected/mysql] Promoting field "${field.name}" to column in ${tableName}`);
308
- let sqlType = "TEXT";
352
+ const isUniqueOrIndexed = field.unique || indexedFields.has(field.name);
353
+ let sqlType = isUniqueOrIndexed ? "VARCHAR(191)" : "TEXT";
309
354
  if (field.type === "number") sqlType = "DECIMAL(19,4)";
310
355
  if (field.type === "boolean") sqlType = "TINYINT(1)";
311
356
  if (field.type === "date" || field.type === "datetime") sqlType = "DATETIME(3)";
357
+ if (field.type === "textarea" || field.type === "richText" || field.type === "json") sqlType = "LONGTEXT";
312
358
  try {
313
359
  await this.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${field.name}\` ${sqlType}`);
314
360
  existingCols.push(field.name);
@@ -336,6 +382,61 @@ FIX INSTRUCTIONS:
336
382
  }
337
383
  }
338
384
  }
385
+ const rawIndices = await this.query(`SHOW INDEX FROM \`${tableName}\``);
386
+ const existingIndicesRows = Array.isArray(rawIndices?.[0]) ? rawIndices[0] : Array.isArray(rawIndices) ? rawIndices : [];
387
+ const existingIndexNames = new Set(existingIndicesRows.map((r) => r?.Key_name).filter(Boolean));
388
+ for (const field of fields) {
389
+ if (field.unique) {
390
+ const idxName = `uniq_${tableName}_${field.name}`;
391
+ if (!existingIndexNames.has(idxName) && !existingIndexNames.has(field.name)) {
392
+ try {
393
+ const [colInfoRows] = await this.query(`SHOW COLUMNS FROM \`${tableName}\` WHERE Field = ?`, [field.name]);
394
+ const colType = colInfoRows?.[0]?.Type?.toLowerCase() || "";
395
+ if (colType.includes("text")) {
396
+ await this.query(`ALTER TABLE \`${tableName}\` MODIFY COLUMN \`${field.name}\` VARCHAR(191)`);
397
+ }
398
+ } catch {
399
+ }
400
+ try {
401
+ await this.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE INDEX \`${idxName}\` (\`${field.name}\`)`);
402
+ existingIndexNames.add(idxName);
403
+ } catch (err) {
404
+ if (err.errno !== 1061 && err.code !== "ER_DUP_KEYNAME") {
405
+ console.warn(`[dyrected/mysql] Could not create unique index ${idxName} on ${tableName}:`, err.message);
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+ if (Array.isArray(indexes)) {
412
+ for (const idx of indexes) {
413
+ if (!Array.isArray(idx.fields) || idx.fields.length === 0) continue;
414
+ const isUnique = Boolean(idx.unique);
415
+ const idxName = idx.name || `${isUnique ? "uniq" : "idx"}_${tableName}_${idx.fields.join("_")}`;
416
+ if (!existingIndexNames.has(idxName)) {
417
+ for (const fName of idx.fields) {
418
+ try {
419
+ const [colInfoRows] = await this.query(`SHOW COLUMNS FROM \`${tableName}\` WHERE Field = ?`, [fName]);
420
+ const colType = colInfoRows?.[0]?.Type?.toLowerCase() || "";
421
+ if (colType.includes("text")) {
422
+ await this.query(`ALTER TABLE \`${tableName}\` MODIFY COLUMN \`${fName}\` VARCHAR(191)`);
423
+ }
424
+ } catch {
425
+ }
426
+ }
427
+ const uniqueKeyword = isUnique ? "UNIQUE " : "";
428
+ const colList = idx.fields.map((f) => `\`${f.replace(/`/g, "``")}\``).join(", ");
429
+ try {
430
+ await this.query(`ALTER TABLE \`${tableName}\` ADD ${uniqueKeyword}INDEX \`${idxName}\` (${colList})`);
431
+ existingIndexNames.add(idxName);
432
+ } catch (err) {
433
+ if (err.errno !== 1061 && err.code !== "ER_DUP_KEYNAME") {
434
+ console.warn(`[dyrected/mysql] Could not create index ${idxName} on ${tableName}:`, err.message);
435
+ }
436
+ }
437
+ }
438
+ }
439
+ }
339
440
  this.ensuredTables.add(slug);
340
441
  resolveLock();
341
442
  } catch (err) {
@@ -358,8 +459,9 @@ FIX INSTRUCTIONS:
358
459
  const result = (0, import_core.parseSqlWhere)(
359
460
  args.where,
360
461
  (field) => {
361
- if (field === "createdAt") return "`created_at`";
362
- if (field === "updatedAt") return "`updated_at`";
462
+ if (field === "id") return "`id`";
463
+ if (field === "createdAt" || field === "created_at") return "`created_at`";
464
+ if (field === "updatedAt" || field === "updated_at") return "`updated_at`";
363
465
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
364
466
  return `\`${field}\``;
365
467
  }
@@ -371,21 +473,32 @@ FIX INSTRUCTIONS:
371
473
  whereParams = result.params;
372
474
  }
373
475
  const sort = normalizeMysqlSort(args.sort, existingCols);
476
+ const lock = this.inTransaction || args.lock === "for-update" ? " FOR UPDATE" : "";
374
477
  const [countRows] = await this.query(
375
478
  `SELECT COUNT(*) AS total FROM \`${tableName}\` ${whereSql}`,
376
479
  whereParams
377
480
  );
378
481
  const total = Number(countRows[0].total);
379
482
  const [rows] = await this.query(
380
- `SELECT * FROM \`${tableName}\` ${whereSql} ORDER BY ${sort} LIMIT ? OFFSET ?`,
483
+ `SELECT * FROM \`${tableName}\` ${whereSql} ORDER BY ${sort} LIMIT ? OFFSET ?${lock}`,
381
484
  [...whereParams, limit, offset]
382
485
  );
383
- const docs = rows.map((r) => ({
384
- id: r.id,
385
- ...JSON.parse(typeof r.data === "string" ? r.data : JSON.stringify(r.data)),
386
- createdAt: r.created_at,
387
- updatedAt: r.updated_at
388
- }));
486
+ const docs = rows.map((r) => {
487
+ const parsedData = JSON.parse(typeof r.data === "string" ? r.data : JSON.stringify(r.data));
488
+ const promotedOverlay = {};
489
+ for (const col of existingCols) {
490
+ if (!["id", "data", "created_at", "updated_at"].includes(col) && r[col] !== void 0 && r[col] !== null) {
491
+ promotedOverlay[col] = r[col];
492
+ }
493
+ }
494
+ return {
495
+ id: r.id,
496
+ ...parsedData,
497
+ ...promotedOverlay,
498
+ createdAt: r.created_at,
499
+ updatedAt: r.updated_at
500
+ };
501
+ });
389
502
  const totalPages = Math.ceil(total / limit);
390
503
  return {
391
504
  docs,
@@ -400,13 +513,22 @@ FIX INSTRUCTIONS:
400
513
  async findOne(params) {
401
514
  if (!this.inTransaction) await this.ensureTable(params.collection);
402
515
  const tableName = this.getTableName(params.collection);
403
- const lock = this.inTransaction ? " FOR UPDATE" : "";
516
+ const lock = this.inTransaction || params.lock === "for-update" ? " FOR UPDATE" : "";
404
517
  const [rows] = await this.query(`SELECT * FROM \`${tableName}\` WHERE id = ?${lock}`, [params.id]);
405
518
  const row = rows[0];
406
519
  if (!row) return null;
520
+ const parsedData = JSON.parse(typeof row.data === "string" ? row.data : JSON.stringify(row.data));
521
+ const existingCols = await this.getTableColumns(tableName);
522
+ const promotedOverlay = {};
523
+ for (const col of existingCols) {
524
+ if (!["id", "data", "created_at", "updated_at"].includes(col) && row[col] !== void 0 && row[col] !== null) {
525
+ promotedOverlay[col] = row[col];
526
+ }
527
+ }
407
528
  return {
408
529
  id: row.id,
409
- ...JSON.parse(typeof row.data === "string" ? row.data : JSON.stringify(row.data)),
530
+ ...parsedData,
531
+ ...promotedOverlay,
410
532
  createdAt: row.created_at,
411
533
  updatedAt: row.updated_at
412
534
  };
@@ -415,7 +537,8 @@ FIX INSTRUCTIONS:
415
537
  if (!this.inTransaction) await this.ensureTable(params.collection);
416
538
  const tableName = this.getTableName(params.collection);
417
539
  const existingCols = await this.getTableColumns(tableName);
418
- const id = params.data.id ?? Math.random().toString(36).substring(7);
540
+ const colConfig = this.collectionConfigs.get(params.collection);
541
+ const id = params.data.id ?? (0, import_core.generateDocumentId)(colConfig || params.collection);
419
542
  const now = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
420
543
  const data = { ...params.data };
421
544
  delete data.id;
@@ -432,33 +555,115 @@ FIX INSTRUCTIONS:
432
555
  const placeholders = colNames.map(() => "?").join(", ");
433
556
  const values = [id, JSON.stringify(data), now, now, ...Object.values(promotedValues)];
434
557
  await this.query(`INSERT INTO \`${tableName}\` (${colNames.join(", ")}) VALUES (${placeholders})`, values);
435
- return { id, ...data, createdAt: now, updatedAt: now };
558
+ return { id, ...data, ...promotedValues, createdAt: now, updatedAt: now };
436
559
  }
437
560
  async update(params) {
438
561
  if (!this.inTransaction) await this.ensureTable(params.collection);
439
562
  const tableName = this.getTableName(params.collection);
440
563
  const existingCols = await this.getTableColumns(tableName);
441
- const existing = await this.findOne({ collection: params.collection, id: params.id });
442
564
  const now = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
443
- const merged = { ...existing ?? {}, ...params.data };
444
- delete merged.id;
445
- delete merged.createdAt;
446
- delete merged.updatedAt;
447
- const promotedValues = {};
448
- for (const col of existingCols) {
449
- if (["id", "data", "created_at", "updated_at"].includes(col)) continue;
450
- if (merged[col] !== void 0) {
451
- promotedValues[col] = merged[col];
565
+ let whereSql = "";
566
+ let whereParams = [];
567
+ if (params.id) {
568
+ whereSql = "WHERE id = ?";
569
+ whereParams = [params.id];
570
+ if (params.where && Object.keys(params.where).length > 0) {
571
+ const parsed = (0, import_core.parseSqlWhere)(
572
+ params.where,
573
+ (field) => {
574
+ if (field === "id") return "`id`";
575
+ if (field === "createdAt" || field === "created_at") return "`created_at`";
576
+ if (field === "updatedAt" || field === "updated_at") return "`updated_at`";
577
+ if (existingCols.includes(field) && !["id", "data"].includes(field)) {
578
+ return `\`${field}\``;
579
+ }
580
+ return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
581
+ },
582
+ "?"
583
+ );
584
+ whereSql += ` AND (${parsed.sql})`;
585
+ whereParams.push(...parsed.params);
586
+ }
587
+ } else if (params.where && Object.keys(params.where).length > 0) {
588
+ const parsed = (0, import_core.parseSqlWhere)(
589
+ params.where,
590
+ (field) => {
591
+ if (field === "id") return "`id`";
592
+ if (field === "createdAt" || field === "created_at") return "`created_at`";
593
+ if (field === "updatedAt" || field === "updated_at") return "`updated_at`";
594
+ if (existingCols.includes(field) && !["id", "data"].includes(field)) {
595
+ return `\`${field}\``;
596
+ }
597
+ return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
598
+ },
599
+ "?"
600
+ );
601
+ whereSql = `WHERE ${parsed.sql}`;
602
+ whereParams = parsed.params;
603
+ } else {
604
+ throw new Error("update requires either id or where clause");
605
+ }
606
+ const setClauses = [];
607
+ const setParams = [];
608
+ const jsonPaths = [];
609
+ const jsonParams = [];
610
+ for (const [key, val] of Object.entries(params.data)) {
611
+ if (["id", "createdAt", "updatedAt"].includes(key)) continue;
612
+ const isPromoted = existingCols.includes(key) && !["id", "data", "created_at", "updated_at"].includes(key);
613
+ if (isNumericOp(val)) {
614
+ const delta = Number((val.increment ?? 0) - (val.decrement ?? 0));
615
+ if (isPromoted) {
616
+ const escapedCol = escapeMysqlIdentifier(key);
617
+ setClauses.push(`${escapedCol} = COALESCE(${escapedCol}, 0) + ?`);
618
+ setParams.push(delta);
619
+ jsonPaths.push(`'$.${key}', ${escapedCol}`);
620
+ } else {
621
+ jsonPaths.push(`'$.${key}', CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${key}')), 0) AS DECIMAL(19,4)) + ?`);
622
+ jsonParams.push(delta);
623
+ }
624
+ } else {
625
+ if (isPromoted) {
626
+ const escapedCol = escapeMysqlIdentifier(key);
627
+ setClauses.push(`${escapedCol} = ?`);
628
+ setParams.push(val);
629
+ jsonPaths.push(`'$.${key}', ${escapedCol}`);
630
+ } else if (val === null) {
631
+ jsonPaths.push(`'$.${key}', CAST('null' AS JSON)`);
632
+ } else if (typeof val === "object") {
633
+ jsonPaths.push(`'$.${key}', CAST(? AS JSON)`);
634
+ jsonParams.push(JSON.stringify(val));
635
+ } else {
636
+ jsonPaths.push(`'$.${key}', ?`);
637
+ jsonParams.push(val);
638
+ }
639
+ }
640
+ }
641
+ setClauses.push("updated_at = ?");
642
+ setParams.push(now);
643
+ if (jsonPaths.length > 0) {
644
+ setClauses.push(`data = JSON_SET(COALESCE(data, '{}'), ${jsonPaths.join(", ")})`);
645
+ setParams.push(...jsonParams);
646
+ }
647
+ const updateSql = `UPDATE \`${tableName}\` SET ${setClauses.join(", ")} ${whereSql}`;
648
+ const [res] = await this.query(updateSql, [...setParams, ...whereParams]);
649
+ let targetId = params.id;
650
+ if (!targetId && params.where) {
651
+ const found = await this.find({ collection: params.collection, where: params.where, limit: 1 });
652
+ targetId = found.docs[0]?.id;
653
+ }
654
+ if (targetId) {
655
+ const doc = await this.findOne({ collection: params.collection, id: targetId });
656
+ if (doc) {
657
+ doc.affectedRows = res?.affectedRows ?? 0;
658
+ return doc;
452
659
  }
453
660
  }
454
- const setClauses = ["data = ?", "updated_at = ?", ...Object.keys(promotedValues).map((k) => `\`${k}\` = ?`)];
455
- const values = [JSON.stringify(merged), now, ...Object.values(promotedValues), params.id];
456
- await this.query(`UPDATE \`${tableName}\` SET ${setClauses.join(", ")} WHERE id = ?`, values);
457
- return { id: params.id, ...merged, createdAt: existing?.createdAt, updatedAt: now };
661
+ return { id: targetId, ...params.data, affectedRows: res?.affectedRows ?? 0, updatedAt: now };
458
662
  }
459
663
  async sync(collections) {
460
664
  for (const col of collections) {
461
- await this.ensureTable(col.slug, col.fields);
665
+ this.collectionConfigs.set(col.slug, col);
666
+ await this.ensureTable(col.slug, col.fields, col.indexes);
462
667
  }
463
668
  }
464
669
  async delete(params) {
package/dist/index.d.cts CHANGED
@@ -20,11 +20,13 @@ declare class MysqlAdapter implements DatabaseAdapter {
20
20
  private tableLocks;
21
21
  private ensuredTables;
22
22
  private tableColumnsCache;
23
+ private collectionConfigs;
23
24
  constructor(config: MysqlAdapterConfig);
24
25
  private handleConnectionError;
25
26
  private retryOperation;
26
27
  private ensureInitialized;
27
28
  private initialize;
29
+ private handleDatabaseError;
28
30
  private query;
29
31
  execute(sql: string, params?: any[]): Promise<any>;
30
32
  private getTableName;
@@ -36,10 +38,13 @@ declare class MysqlAdapter implements DatabaseAdapter {
36
38
  limit?: number;
37
39
  page?: number;
38
40
  sort?: string;
41
+ fields?: any[];
42
+ lock?: "for-update";
39
43
  }): Promise<PaginatedResult>;
40
44
  findOne(params: {
41
45
  collection: string;
42
46
  id: string;
47
+ lock?: "for-update";
43
48
  }): Promise<any>;
44
49
  create(params: {
45
50
  collection: string;
@@ -47,7 +52,8 @@ declare class MysqlAdapter implements DatabaseAdapter {
47
52
  }): Promise<any>;
48
53
  update(params: {
49
54
  collection: string;
50
- id: string;
55
+ id?: string;
56
+ where?: any;
51
57
  data: any;
52
58
  }): Promise<any>;
53
59
  sync(collections: any[]): Promise<void>;
package/dist/index.d.ts CHANGED
@@ -20,11 +20,13 @@ declare class MysqlAdapter implements DatabaseAdapter {
20
20
  private tableLocks;
21
21
  private ensuredTables;
22
22
  private tableColumnsCache;
23
+ private collectionConfigs;
23
24
  constructor(config: MysqlAdapterConfig);
24
25
  private handleConnectionError;
25
26
  private retryOperation;
26
27
  private ensureInitialized;
27
28
  private initialize;
29
+ private handleDatabaseError;
28
30
  private query;
29
31
  execute(sql: string, params?: any[]): Promise<any>;
30
32
  private getTableName;
@@ -36,10 +38,13 @@ declare class MysqlAdapter implements DatabaseAdapter {
36
38
  limit?: number;
37
39
  page?: number;
38
40
  sort?: string;
41
+ fields?: any[];
42
+ lock?: "for-update";
39
43
  }): Promise<PaginatedResult>;
40
44
  findOne(params: {
41
45
  collection: string;
42
46
  id: string;
47
+ lock?: "for-update";
43
48
  }): Promise<any>;
44
49
  create(params: {
45
50
  collection: string;
@@ -47,7 +52,8 @@ declare class MysqlAdapter implements DatabaseAdapter {
47
52
  }): Promise<any>;
48
53
  update(params: {
49
54
  collection: string;
50
- id: string;
55
+ id?: string;
56
+ where?: any;
51
57
  data: any;
52
58
  }): Promise<any>;
53
59
  sync(collections: any[]): Promise<void>;
package/dist/index.js CHANGED
@@ -1,6 +1,22 @@
1
1
  // src/index.ts
2
- import { parseSort, parseSqlWhere } from "@dyrected/core";
2
+ import { parseSort, parseSqlWhere, DuplicateKeyError, generateDocumentId } from "@dyrected/core";
3
3
  import mysql from "mysql2/promise";
4
+ function parseMysqlDuplicateKey(message) {
5
+ const match = message.match(/Duplicate entry '(.*)' for key '(?:[^.]*\.)?(.*)'/);
6
+ if (!match) return {};
7
+ const [, value, keyName] = match;
8
+ let field = keyName;
9
+ if (keyName === "PRIMARY") {
10
+ field = "id";
11
+ } else if (keyName.startsWith("uniq_") || keyName.startsWith("idx_")) {
12
+ const parts = keyName.split("_");
13
+ field = parts[parts.length - 1];
14
+ }
15
+ return { field, value };
16
+ }
17
+ function isNumericOp(val) {
18
+ return val !== null && typeof val === "object" && !Array.isArray(val) && ("increment" in val || "decrement" in val);
19
+ }
4
20
  var MYSQL_CLIENT_CACHE_KEY = "__dyrectedMysqlClientCache";
5
21
  function getSharedMysqlClientCache() {
6
22
  const globalScope = globalThis;
@@ -34,6 +50,7 @@ var MysqlAdapter = class {
34
50
  tableLocks = /* @__PURE__ */ new Map();
35
51
  ensuredTables = /* @__PURE__ */ new Set();
36
52
  tableColumnsCache = /* @__PURE__ */ new Map();
53
+ collectionConfigs = /* @__PURE__ */ new Map();
37
54
  constructor(config) {
38
55
  this.config = config;
39
56
  }
@@ -168,7 +185,7 @@ FIX INSTRUCTIONS:
168
185
  `);
169
186
  await pool.query(`
170
187
  CREATE TABLE IF NOT EXISTS _dyrected_ai_threads (
171
- id VARCHAR(36) PRIMARY KEY,
188
+ id VARCHAR(191) PRIMARY KEY,
172
189
  project_id VARCHAR(255) NOT NULL,
173
190
  user_id VARCHAR(255) NOT NULL,
174
191
  title TEXT,
@@ -179,8 +196,8 @@ FIX INSTRUCTIONS:
179
196
  `);
180
197
  await pool.query(`
181
198
  CREATE TABLE IF NOT EXISTS _dyrected_ai_messages (
182
- id VARCHAR(36) PRIMARY KEY,
183
- thread_id VARCHAR(36) NOT NULL,
199
+ id VARCHAR(191) PRIMARY KEY,
200
+ thread_id VARCHAR(191) NOT NULL,
184
201
  role VARCHAR(50) NOT NULL,
185
202
  content LONGTEXT NOT NULL,
186
203
  created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
@@ -204,12 +221,22 @@ FIX INSTRUCTIONS:
204
221
  throw error;
205
222
  }
206
223
  }
224
+ handleDatabaseError(err) {
225
+ if (err && (err.errno === 1062 || err.code === "ER_DUP_ENTRY" || err.message?.includes("Duplicate entry"))) {
226
+ const { field, value } = parseMysqlDuplicateKey(err.message || "");
227
+ throw new DuplicateKeyError(
228
+ `Duplicate entry${value ? ` '${value}'` : ""} for unique key${field ? ` '${field}'` : ""}`,
229
+ { field, value }
230
+ );
231
+ }
232
+ this.handleConnectionError(err);
233
+ }
207
234
  async query(sql, params) {
208
235
  await this.ensureInitialized();
209
236
  try {
210
237
  return await this.pool.query(sql, params);
211
238
  } catch (err) {
212
- this.handleConnectionError(err);
239
+ this.handleDatabaseError(err);
213
240
  }
214
241
  }
215
242
  async execute(sql, params) {
@@ -217,7 +244,7 @@ FIX INSTRUCTIONS:
217
244
  try {
218
245
  return await this.pool.execute(sql, params);
219
246
  } catch (err) {
220
- this.handleConnectionError(err);
247
+ this.handleDatabaseError(err);
221
248
  }
222
249
  }
223
250
  getTableName(slug) {
@@ -235,7 +262,7 @@ FIX INSTRUCTIONS:
235
262
  this.tableColumnsCache.set(tableName, existingCols);
236
263
  return existingCols;
237
264
  }
238
- async ensureTable(slug, fields = []) {
265
+ async ensureTable(slug, fields = [], indexes = []) {
239
266
  if (slug === "_dyrected_ai_threads" || slug === "_dyrected_ai_messages") {
240
267
  return;
241
268
  }
@@ -259,20 +286,39 @@ FIX INSTRUCTIONS:
259
286
  const tableName = this.getTableName(slug);
260
287
  await this.query(`
261
288
  CREATE TABLE IF NOT EXISTS \`${tableName}\` (
262
- id VARCHAR(36) PRIMARY KEY,
289
+ id VARCHAR(191) PRIMARY KEY,
263
290
  data JSON NOT NULL,
264
291
  created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
265
292
  updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
266
293
  )
267
294
  `);
268
295
  const existingCols = await this.getTableColumns(tableName);
296
+ try {
297
+ const [idCols] = await this.query(`SHOW COLUMNS FROM \`${tableName}\` WHERE Field = 'id'`);
298
+ const idCol = idCols?.[0];
299
+ if (idCol && typeof idCol.Type === "string" && idCol.Type.toLowerCase().includes("varchar(36)")) {
300
+ await this.query(`ALTER TABLE \`${tableName}\` MODIFY COLUMN id VARCHAR(191) NOT NULL`);
301
+ }
302
+ } catch {
303
+ }
304
+ const indexedFields = /* @__PURE__ */ new Set();
305
+ if (Array.isArray(indexes)) {
306
+ for (const idx of indexes) {
307
+ if (Array.isArray(idx.fields)) {
308
+ for (const f of idx.fields) indexedFields.add(f);
309
+ }
310
+ }
311
+ }
269
312
  for (const field of fields) {
270
- if (field.promoted && !existingCols.includes(field.name)) {
313
+ const shouldPromote = field.promoted || field.unique || indexedFields.has(field.name);
314
+ if (shouldPromote && !existingCols.includes(field.name)) {
271
315
  console.log(`[dyrected/mysql] Promoting field "${field.name}" to column in ${tableName}`);
272
- let sqlType = "TEXT";
316
+ const isUniqueOrIndexed = field.unique || indexedFields.has(field.name);
317
+ let sqlType = isUniqueOrIndexed ? "VARCHAR(191)" : "TEXT";
273
318
  if (field.type === "number") sqlType = "DECIMAL(19,4)";
274
319
  if (field.type === "boolean") sqlType = "TINYINT(1)";
275
320
  if (field.type === "date" || field.type === "datetime") sqlType = "DATETIME(3)";
321
+ if (field.type === "textarea" || field.type === "richText" || field.type === "json") sqlType = "LONGTEXT";
276
322
  try {
277
323
  await this.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${field.name}\` ${sqlType}`);
278
324
  existingCols.push(field.name);
@@ -300,6 +346,61 @@ FIX INSTRUCTIONS:
300
346
  }
301
347
  }
302
348
  }
349
+ const rawIndices = await this.query(`SHOW INDEX FROM \`${tableName}\``);
350
+ const existingIndicesRows = Array.isArray(rawIndices?.[0]) ? rawIndices[0] : Array.isArray(rawIndices) ? rawIndices : [];
351
+ const existingIndexNames = new Set(existingIndicesRows.map((r) => r?.Key_name).filter(Boolean));
352
+ for (const field of fields) {
353
+ if (field.unique) {
354
+ const idxName = `uniq_${tableName}_${field.name}`;
355
+ if (!existingIndexNames.has(idxName) && !existingIndexNames.has(field.name)) {
356
+ try {
357
+ const [colInfoRows] = await this.query(`SHOW COLUMNS FROM \`${tableName}\` WHERE Field = ?`, [field.name]);
358
+ const colType = colInfoRows?.[0]?.Type?.toLowerCase() || "";
359
+ if (colType.includes("text")) {
360
+ await this.query(`ALTER TABLE \`${tableName}\` MODIFY COLUMN \`${field.name}\` VARCHAR(191)`);
361
+ }
362
+ } catch {
363
+ }
364
+ try {
365
+ await this.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE INDEX \`${idxName}\` (\`${field.name}\`)`);
366
+ existingIndexNames.add(idxName);
367
+ } catch (err) {
368
+ if (err.errno !== 1061 && err.code !== "ER_DUP_KEYNAME") {
369
+ console.warn(`[dyrected/mysql] Could not create unique index ${idxName} on ${tableName}:`, err.message);
370
+ }
371
+ }
372
+ }
373
+ }
374
+ }
375
+ if (Array.isArray(indexes)) {
376
+ for (const idx of indexes) {
377
+ if (!Array.isArray(idx.fields) || idx.fields.length === 0) continue;
378
+ const isUnique = Boolean(idx.unique);
379
+ const idxName = idx.name || `${isUnique ? "uniq" : "idx"}_${tableName}_${idx.fields.join("_")}`;
380
+ if (!existingIndexNames.has(idxName)) {
381
+ for (const fName of idx.fields) {
382
+ try {
383
+ const [colInfoRows] = await this.query(`SHOW COLUMNS FROM \`${tableName}\` WHERE Field = ?`, [fName]);
384
+ const colType = colInfoRows?.[0]?.Type?.toLowerCase() || "";
385
+ if (colType.includes("text")) {
386
+ await this.query(`ALTER TABLE \`${tableName}\` MODIFY COLUMN \`${fName}\` VARCHAR(191)`);
387
+ }
388
+ } catch {
389
+ }
390
+ }
391
+ const uniqueKeyword = isUnique ? "UNIQUE " : "";
392
+ const colList = idx.fields.map((f) => `\`${f.replace(/`/g, "``")}\``).join(", ");
393
+ try {
394
+ await this.query(`ALTER TABLE \`${tableName}\` ADD ${uniqueKeyword}INDEX \`${idxName}\` (${colList})`);
395
+ existingIndexNames.add(idxName);
396
+ } catch (err) {
397
+ if (err.errno !== 1061 && err.code !== "ER_DUP_KEYNAME") {
398
+ console.warn(`[dyrected/mysql] Could not create index ${idxName} on ${tableName}:`, err.message);
399
+ }
400
+ }
401
+ }
402
+ }
403
+ }
303
404
  this.ensuredTables.add(slug);
304
405
  resolveLock();
305
406
  } catch (err) {
@@ -322,8 +423,9 @@ FIX INSTRUCTIONS:
322
423
  const result = parseSqlWhere(
323
424
  args.where,
324
425
  (field) => {
325
- if (field === "createdAt") return "`created_at`";
326
- if (field === "updatedAt") return "`updated_at`";
426
+ if (field === "id") return "`id`";
427
+ if (field === "createdAt" || field === "created_at") return "`created_at`";
428
+ if (field === "updatedAt" || field === "updated_at") return "`updated_at`";
327
429
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
328
430
  return `\`${field}\``;
329
431
  }
@@ -335,21 +437,32 @@ FIX INSTRUCTIONS:
335
437
  whereParams = result.params;
336
438
  }
337
439
  const sort = normalizeMysqlSort(args.sort, existingCols);
440
+ const lock = this.inTransaction || args.lock === "for-update" ? " FOR UPDATE" : "";
338
441
  const [countRows] = await this.query(
339
442
  `SELECT COUNT(*) AS total FROM \`${tableName}\` ${whereSql}`,
340
443
  whereParams
341
444
  );
342
445
  const total = Number(countRows[0].total);
343
446
  const [rows] = await this.query(
344
- `SELECT * FROM \`${tableName}\` ${whereSql} ORDER BY ${sort} LIMIT ? OFFSET ?`,
447
+ `SELECT * FROM \`${tableName}\` ${whereSql} ORDER BY ${sort} LIMIT ? OFFSET ?${lock}`,
345
448
  [...whereParams, limit, offset]
346
449
  );
347
- const docs = rows.map((r) => ({
348
- id: r.id,
349
- ...JSON.parse(typeof r.data === "string" ? r.data : JSON.stringify(r.data)),
350
- createdAt: r.created_at,
351
- updatedAt: r.updated_at
352
- }));
450
+ const docs = rows.map((r) => {
451
+ const parsedData = JSON.parse(typeof r.data === "string" ? r.data : JSON.stringify(r.data));
452
+ const promotedOverlay = {};
453
+ for (const col of existingCols) {
454
+ if (!["id", "data", "created_at", "updated_at"].includes(col) && r[col] !== void 0 && r[col] !== null) {
455
+ promotedOverlay[col] = r[col];
456
+ }
457
+ }
458
+ return {
459
+ id: r.id,
460
+ ...parsedData,
461
+ ...promotedOverlay,
462
+ createdAt: r.created_at,
463
+ updatedAt: r.updated_at
464
+ };
465
+ });
353
466
  const totalPages = Math.ceil(total / limit);
354
467
  return {
355
468
  docs,
@@ -364,13 +477,22 @@ FIX INSTRUCTIONS:
364
477
  async findOne(params) {
365
478
  if (!this.inTransaction) await this.ensureTable(params.collection);
366
479
  const tableName = this.getTableName(params.collection);
367
- const lock = this.inTransaction ? " FOR UPDATE" : "";
480
+ const lock = this.inTransaction || params.lock === "for-update" ? " FOR UPDATE" : "";
368
481
  const [rows] = await this.query(`SELECT * FROM \`${tableName}\` WHERE id = ?${lock}`, [params.id]);
369
482
  const row = rows[0];
370
483
  if (!row) return null;
484
+ const parsedData = JSON.parse(typeof row.data === "string" ? row.data : JSON.stringify(row.data));
485
+ const existingCols = await this.getTableColumns(tableName);
486
+ const promotedOverlay = {};
487
+ for (const col of existingCols) {
488
+ if (!["id", "data", "created_at", "updated_at"].includes(col) && row[col] !== void 0 && row[col] !== null) {
489
+ promotedOverlay[col] = row[col];
490
+ }
491
+ }
371
492
  return {
372
493
  id: row.id,
373
- ...JSON.parse(typeof row.data === "string" ? row.data : JSON.stringify(row.data)),
494
+ ...parsedData,
495
+ ...promotedOverlay,
374
496
  createdAt: row.created_at,
375
497
  updatedAt: row.updated_at
376
498
  };
@@ -379,7 +501,8 @@ FIX INSTRUCTIONS:
379
501
  if (!this.inTransaction) await this.ensureTable(params.collection);
380
502
  const tableName = this.getTableName(params.collection);
381
503
  const existingCols = await this.getTableColumns(tableName);
382
- const id = params.data.id ?? Math.random().toString(36).substring(7);
504
+ const colConfig = this.collectionConfigs.get(params.collection);
505
+ const id = params.data.id ?? generateDocumentId(colConfig || params.collection);
383
506
  const now = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
384
507
  const data = { ...params.data };
385
508
  delete data.id;
@@ -396,33 +519,115 @@ FIX INSTRUCTIONS:
396
519
  const placeholders = colNames.map(() => "?").join(", ");
397
520
  const values = [id, JSON.stringify(data), now, now, ...Object.values(promotedValues)];
398
521
  await this.query(`INSERT INTO \`${tableName}\` (${colNames.join(", ")}) VALUES (${placeholders})`, values);
399
- return { id, ...data, createdAt: now, updatedAt: now };
522
+ return { id, ...data, ...promotedValues, createdAt: now, updatedAt: now };
400
523
  }
401
524
  async update(params) {
402
525
  if (!this.inTransaction) await this.ensureTable(params.collection);
403
526
  const tableName = this.getTableName(params.collection);
404
527
  const existingCols = await this.getTableColumns(tableName);
405
- const existing = await this.findOne({ collection: params.collection, id: params.id });
406
528
  const now = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").replace("Z", "");
407
- const merged = { ...existing ?? {}, ...params.data };
408
- delete merged.id;
409
- delete merged.createdAt;
410
- delete merged.updatedAt;
411
- const promotedValues = {};
412
- for (const col of existingCols) {
413
- if (["id", "data", "created_at", "updated_at"].includes(col)) continue;
414
- if (merged[col] !== void 0) {
415
- promotedValues[col] = merged[col];
529
+ let whereSql = "";
530
+ let whereParams = [];
531
+ if (params.id) {
532
+ whereSql = "WHERE id = ?";
533
+ whereParams = [params.id];
534
+ if (params.where && Object.keys(params.where).length > 0) {
535
+ const parsed = parseSqlWhere(
536
+ params.where,
537
+ (field) => {
538
+ if (field === "id") return "`id`";
539
+ if (field === "createdAt" || field === "created_at") return "`created_at`";
540
+ if (field === "updatedAt" || field === "updated_at") return "`updated_at`";
541
+ if (existingCols.includes(field) && !["id", "data"].includes(field)) {
542
+ return `\`${field}\``;
543
+ }
544
+ return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
545
+ },
546
+ "?"
547
+ );
548
+ whereSql += ` AND (${parsed.sql})`;
549
+ whereParams.push(...parsed.params);
550
+ }
551
+ } else if (params.where && Object.keys(params.where).length > 0) {
552
+ const parsed = parseSqlWhere(
553
+ params.where,
554
+ (field) => {
555
+ if (field === "id") return "`id`";
556
+ if (field === "createdAt" || field === "created_at") return "`created_at`";
557
+ if (field === "updatedAt" || field === "updated_at") return "`updated_at`";
558
+ if (existingCols.includes(field) && !["id", "data"].includes(field)) {
559
+ return `\`${field}\``;
560
+ }
561
+ return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
562
+ },
563
+ "?"
564
+ );
565
+ whereSql = `WHERE ${parsed.sql}`;
566
+ whereParams = parsed.params;
567
+ } else {
568
+ throw new Error("update requires either id or where clause");
569
+ }
570
+ const setClauses = [];
571
+ const setParams = [];
572
+ const jsonPaths = [];
573
+ const jsonParams = [];
574
+ for (const [key, val] of Object.entries(params.data)) {
575
+ if (["id", "createdAt", "updatedAt"].includes(key)) continue;
576
+ const isPromoted = existingCols.includes(key) && !["id", "data", "created_at", "updated_at"].includes(key);
577
+ if (isNumericOp(val)) {
578
+ const delta = Number((val.increment ?? 0) - (val.decrement ?? 0));
579
+ if (isPromoted) {
580
+ const escapedCol = escapeMysqlIdentifier(key);
581
+ setClauses.push(`${escapedCol} = COALESCE(${escapedCol}, 0) + ?`);
582
+ setParams.push(delta);
583
+ jsonPaths.push(`'$.${key}', ${escapedCol}`);
584
+ } else {
585
+ jsonPaths.push(`'$.${key}', CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${key}')), 0) AS DECIMAL(19,4)) + ?`);
586
+ jsonParams.push(delta);
587
+ }
588
+ } else {
589
+ if (isPromoted) {
590
+ const escapedCol = escapeMysqlIdentifier(key);
591
+ setClauses.push(`${escapedCol} = ?`);
592
+ setParams.push(val);
593
+ jsonPaths.push(`'$.${key}', ${escapedCol}`);
594
+ } else if (val === null) {
595
+ jsonPaths.push(`'$.${key}', CAST('null' AS JSON)`);
596
+ } else if (typeof val === "object") {
597
+ jsonPaths.push(`'$.${key}', CAST(? AS JSON)`);
598
+ jsonParams.push(JSON.stringify(val));
599
+ } else {
600
+ jsonPaths.push(`'$.${key}', ?`);
601
+ jsonParams.push(val);
602
+ }
603
+ }
604
+ }
605
+ setClauses.push("updated_at = ?");
606
+ setParams.push(now);
607
+ if (jsonPaths.length > 0) {
608
+ setClauses.push(`data = JSON_SET(COALESCE(data, '{}'), ${jsonPaths.join(", ")})`);
609
+ setParams.push(...jsonParams);
610
+ }
611
+ const updateSql = `UPDATE \`${tableName}\` SET ${setClauses.join(", ")} ${whereSql}`;
612
+ const [res] = await this.query(updateSql, [...setParams, ...whereParams]);
613
+ let targetId = params.id;
614
+ if (!targetId && params.where) {
615
+ const found = await this.find({ collection: params.collection, where: params.where, limit: 1 });
616
+ targetId = found.docs[0]?.id;
617
+ }
618
+ if (targetId) {
619
+ const doc = await this.findOne({ collection: params.collection, id: targetId });
620
+ if (doc) {
621
+ doc.affectedRows = res?.affectedRows ?? 0;
622
+ return doc;
416
623
  }
417
624
  }
418
- const setClauses = ["data = ?", "updated_at = ?", ...Object.keys(promotedValues).map((k) => `\`${k}\` = ?`)];
419
- const values = [JSON.stringify(merged), now, ...Object.values(promotedValues), params.id];
420
- await this.query(`UPDATE \`${tableName}\` SET ${setClauses.join(", ")} WHERE id = ?`, values);
421
- return { id: params.id, ...merged, createdAt: existing?.createdAt, updatedAt: now };
625
+ return { id: targetId, ...params.data, affectedRows: res?.affectedRows ?? 0, updatedAt: now };
422
626
  }
423
627
  async sync(collections) {
424
628
  for (const col of collections) {
425
- await this.ensureTable(col.slug, col.fields);
629
+ this.collectionConfigs.set(col.slug, col);
630
+ await this.ensureTable(col.slug, col.fields, col.indexes);
426
631
  }
427
632
  }
428
633
  async delete(params) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyrected/db-mysql",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "MySQL adapter for Dyrected CMS",
5
5
  "type": "module",
6
6
  "files": [
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "mysql2": "^3.22.3",
21
- "@dyrected/core": "^2.14.0"
21
+ "@dyrected/core": "^2.16.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "tsup": "^8.0.0",