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