@atscript/db-mysql 0.1.154 → 0.1.155

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
@@ -183,6 +183,15 @@ const mysqlDialect = {
183
183
  calendarBucket: mysqlCalendarBucket,
184
184
  bucketAliasInHaving: true,
185
185
  jsonExtract: mysqlJsonExtract,
186
+ jsonExtractText: (quotedCol, path) => {
187
+ const e = `JSON_EXTRACT(${quotedCol}, ${(0, _atscript_db_sql_tools.jsonDollarPath)(path)})`;
188
+ return `CASE WHEN JSON_TYPE(${e}) = 'NULL' THEN NULL ELSE JSON_UNQUOTE(${e}) END`;
189
+ },
190
+ jsonExtractValue: (quotedCol, path) => {
191
+ const e = `JSON_EXTRACT(${quotedCol}, ${(0, _atscript_db_sql_tools.jsonDollarPath)(path)})`;
192
+ return `CASE WHEN JSON_TYPE(${e}) = 'NULL' THEN NULL ELSE ${e} END`;
193
+ },
194
+ jsonFromText: (quotedCol) => `IF(JSON_VALID(${quotedCol}), ${quotedCol}, JSON_QUOTE(${quotedCol}))`,
186
195
  castDouble: (expr) => `CAST(${expr} AS DOUBLE)`,
187
196
  createViewPrefix: "CREATE OR REPLACE VIEW"
188
197
  };
@@ -569,6 +578,20 @@ function epochMsToUtcDatetime(ms, fsp = 0) {
569
578
  */
570
579
  const TZ_PROBE_SQL = "SELECT CONVERT_TZ('2040-06-01 12:00:00', '+00:00', ?) IS NULL AS missing, TIMESTAMPDIFF(MINUTE, '2040-06-01 12:00:00', CONVERT_TZ('2040-06-01 12:00:00', '+00:00', '+01:00')) AS shift";
571
580
  const isZero = (v) => v === 0 || v === "0";
581
+ /** A live numeric column type (`INFORMATION_SCHEMA.COLUMNS.COLUMN_TYPE`). */
582
+ const NUMERIC_SQL_TYPE = /^(double|float|real|decimal|numeric|bigint|int|integer|mediumint|smallint|tinyint)\b/i;
583
+ /** A live text column type — e.g. the column atscript-db <= 0.1.154 created for a `T | null` union. */
584
+ const TEXT_SQL_TYPE = /^(tinytext|text|mediumtext|longtext|varchar|char)\b/i;
585
+ /**
586
+ * Whether the type change of `field` from the live `existingType` converts
587
+ * epoch ms to a native TIMESTAMP / DATETIME (through a temp column, see
588
+ * `_migrateEpochColumnToTemporal`); `fromText`: the live column is text.
589
+ */
590
+ function epochSource(field, existingType) {
591
+ if (!isMysqlTimestampColumn(field)) return void 0;
592
+ const fromText = TEXT_SQL_TYPE.test(existingType);
593
+ return fromText || NUMERIC_SQL_TYPE.test(existingType) ? { fromText } : void 0;
594
+ }
572
595
  /** Drivers whose session `sql_mode` was already checked for strictness (see `_warnNonStrictMode`). */
573
596
  const nonStrictChecked = /* @__PURE__ */ new WeakSet();
574
597
  /**
@@ -602,6 +625,18 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
602
625
  _collation = "utf8mb4_unicode_ci";
603
626
  _autoIncrementStart;
604
627
  _incrementFields = /* @__PURE__ */ new Set();
628
+ /**
629
+ * Changes of columns leaving a pending primary key that `syncColumns` left
630
+ * to the next `rebuildPrimaryKey` (re-declared in its swap statement).
631
+ */
632
+ _leavingKeyColumns = /* @__PURE__ */ new Map();
633
+ /**
634
+ * Columns leaving a pending primary key that also convert from epoch ms to
635
+ * TIMESTAMP / DATETIME: the temp-column conversion drops the column, which
636
+ * a key column cannot be — the next `rebuildPrimaryKey` converts them
637
+ * after its swap.
638
+ */
639
+ _epochAfterRekey = /* @__PURE__ */ new Map();
605
640
  _onUpdateFields = /* @__PURE__ */ new Map();
606
641
  /** Whether the connected MySQL instance supports native VECTOR type (MySQL 9.0+). */
607
642
  _supportsVector;
@@ -1520,12 +1555,22 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1520
1555
  purpose
1521
1556
  };
1522
1557
  }
1558
+ /** The quoted table `tableName` of the adapter's schema, or the adapter's own. */
1559
+ _quotedTable(tableName) {
1560
+ return quoteTableName(tableName ? this._schema ? `${this._schema}.${tableName}` : tableName : this.resolveTableName());
1561
+ }
1523
1562
  async hasRows(tableName) {
1524
- const sql = `SELECT EXISTS(SELECT 1 FROM ${tableName ? quoteTableName(this._schema ? `${this._schema}.${tableName}` : tableName) : quoteTableName(this.resolveTableName())}) AS present`;
1563
+ const sql = `SELECT EXISTS(SELECT 1 FROM ${this._quotedTable(tableName)}) AS present`;
1525
1564
  this._log(sql);
1526
1565
  const row = await this._exec().get(sql, []);
1527
1566
  return Boolean(Number(row?.present ?? 0));
1528
1567
  }
1568
+ async countKeyViolations(columns, tableName) {
1569
+ const sql = (0, _atscript_db_sql_tools.buildKeyViolationCount)(mysqlDialect, this._quotedTable(tableName), columns);
1570
+ this._log(sql);
1571
+ const row = await this._exec().get(sql, []);
1572
+ return Number(row?.violations ?? 0);
1573
+ }
1529
1574
  /** Live foreign keys referencing `tableName` (any table of the schema). */
1530
1575
  async getReferencingForeignKeys(tableName) {
1531
1576
  const rows = await this._exec().all(`SELECT TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_COLUMN_NAME
@@ -1564,7 +1609,8 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1564
1609
  * NOT NULL) together with the key swap — so an AUTO_INCREMENT column never
1565
1610
  * exists without a key (ER 1075), in safe mode or otherwise. A demoted key
1566
1611
  * column loses AUTO_INCREMENT in the same statement (pre-flight guarantees
1567
- * its model no longer declares increment). Called on an empty table only.
1612
+ * its model no longer declares increment). Called on an empty table or one
1613
+ * whose rows satisfy the new key (`countKeyViolations`).
1568
1614
  */
1569
1615
  async rebuildPrimaryKey(change) {
1570
1616
  const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
@@ -1575,6 +1621,9 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1575
1621
  if (field) clauses.push(`MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`);
1576
1622
  }
1577
1623
  if (change.from.length > 0) {
1624
+ const leaving = this._leavingKeyColumns;
1625
+ this._leavingKeyColumns = /* @__PURE__ */ new Map();
1626
+ for (const field of leaving.values()) if (!to.has(field.physicalName) && !this._epochAfterRekey.has(field.physicalName)) clauses.push(`MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`);
1578
1627
  const placeholders = change.from.map(() => "?").join(", ");
1579
1628
  const live = await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS
1580
1629
  WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
@@ -1584,7 +1633,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1584
1633
  ...change.from
1585
1634
  ]);
1586
1635
  for (const col of live) {
1587
- if (to.has(col.COLUMN_NAME) || !/auto_increment/i.test(col.EXTRA ?? "")) continue;
1636
+ if (to.has(col.COLUMN_NAME) || leaving.has(col.COLUMN_NAME) || !/auto_increment/i.test(col.EXTRA ?? "")) continue;
1588
1637
  const field = fields.get(col.COLUMN_NAME);
1589
1638
  const def = field ? buildColumnDefinition(field, this._columnCtx("modify")).def : `${qi(col.COLUMN_NAME)} ${col.COLUMN_TYPE.toUpperCase()} NOT NULL`;
1590
1639
  clauses.push(`MODIFY COLUMN ${def}`);
@@ -1592,10 +1641,18 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1592
1641
  clauses.push("DROP PRIMARY KEY");
1593
1642
  }
1594
1643
  if (change.to.length > 0) clauses.push(`ADD PRIMARY KEY (${change.to.map((c) => qi(c)).join(", ")})`);
1595
- if (clauses.length === 0) return;
1596
- const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ${clauses.join(", ")}`;
1597
- this._log(ddl);
1598
- await this._withStrictSession((conn) => conn.exec(ddl));
1644
+ const epochAfter = this._epochAfterRekey;
1645
+ this._epochAfterRekey = /* @__PURE__ */ new Map();
1646
+ if (clauses.length === 0 && epochAfter.size === 0) return;
1647
+ const tableName = this.resolveTableName();
1648
+ const ddl = `ALTER TABLE ${quoteTableName(tableName)} ${clauses.join(", ")}`;
1649
+ await this._withStrictSession(async (conn) => {
1650
+ if (clauses.length > 0) {
1651
+ this._log(ddl);
1652
+ await conn.exec(ddl);
1653
+ }
1654
+ for (const { field, fromText } of epochAfter.values()) await this._migrateEpochColumnToTemporal(conn, tableName, field, this.typeMapper(field), fromText);
1655
+ });
1599
1656
  }
1600
1657
  async _ensureView() {
1601
1658
  const view = this._table;
@@ -1635,6 +1692,13 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1635
1692
  const tableName = this.resolveTableName();
1636
1693
  const added = [];
1637
1694
  const renamed = [];
1695
+ const keyChange = diff.primaryKeyChanged;
1696
+ const entering = new Set(keyChange?.to ?? []);
1697
+ const leaving = new Set(keyChange?.from.filter((c) => !entering.has(c)) ?? []);
1698
+ for (const { field, existingType } of diff.typeChanged ?? []) {
1699
+ const name = field.physicalName;
1700
+ if ((keyChange ? keyChange.from.includes(name) && entering.has(name) : field.isPrimaryKey) && epochSource(field, existingType) !== void 0) throw new Error(`Cannot convert primary-key column "${name}" from epoch milliseconds to ${this.typeMapper(field)} — migrate it manually.`);
1701
+ }
1638
1702
  for (const { field, oldName } of diff.renamed ?? []) {
1639
1703
  const ddl = `ALTER TABLE ${quoteTableName(tableName)} RENAME COLUMN ${qi(oldName)} TO ${qi(field.physicalName)}`;
1640
1704
  this._log(ddl);
@@ -1654,10 +1718,27 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1654
1718
  }
1655
1719
  added.push(field.physicalName);
1656
1720
  }
1657
- const enteringKey = new Set(diff.primaryKeyChanged?.to ?? []);
1721
+ this._leavingKeyColumns = /* @__PURE__ */ new Map();
1722
+ this._epochAfterRekey = /* @__PURE__ */ new Map();
1723
+ const rekeyed = (field) => {
1724
+ if (leaving.has(field.physicalName)) {
1725
+ this._leavingKeyColumns.set(field.physicalName, field);
1726
+ return true;
1727
+ }
1728
+ return entering.has(field.physicalName);
1729
+ };
1658
1730
  const modified = /* @__PURE__ */ new Map();
1659
- for (const { field } of diff.typeChanged ?? []) {
1660
- if (enteringKey.has(field.physicalName)) continue;
1731
+ for (const { field, existingType } of diff.typeChanged ?? []) {
1732
+ const epoch = epochSource(field, existingType);
1733
+ if (epoch) {
1734
+ if (leaving.has(field.physicalName)) this._epochAfterRekey.set(field.physicalName, {
1735
+ field,
1736
+ fromText: epoch.fromText
1737
+ });
1738
+ else await this._migrateEpochColumnToTemporal(conn, tableName, field, this.typeMapper(field), epoch.fromText);
1739
+ continue;
1740
+ }
1741
+ if (rekeyed(field)) continue;
1661
1742
  const sqlType = this.typeMapper(field);
1662
1743
  if (field.isGeoPoint && !field.encrypted && sqlType.startsWith("POINT")) {
1663
1744
  await this._migrateJsonColumnToPoint(conn, tableName, field);
@@ -1666,7 +1747,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1666
1747
  modified.set(field.physicalName, field);
1667
1748
  }
1668
1749
  for (const { field } of diff.nullableChanged ?? []) {
1669
- if (enteringKey.has(field.physicalName)) continue;
1750
+ if (rekeyed(field)) continue;
1670
1751
  if (!field.optional) {
1671
1752
  const sqlType = this.typeMapper(field);
1672
1753
  const fallback = field.defaultValue?.kind === "value" ? mysqlDefaultLiteral(sqlType, field.designType, field.defaultValue.value) : mysqlTypeDefault(sqlType, field);
@@ -1676,7 +1757,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1676
1757
  }
1677
1758
  modified.set(field.physicalName, field);
1678
1759
  }
1679
- for (const { field } of diff.defaultChanged ?? []) if (!enteringKey.has(field.physicalName)) modified.set(field.physicalName, field);
1760
+ for (const { field } of diff.defaultChanged ?? []) if (!rekeyed(field)) modified.set(field.physicalName, field);
1680
1761
  for (const field of modified.values()) {
1681
1762
  const ddl = `ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`;
1682
1763
  this._log(ddl);
@@ -1732,6 +1813,20 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
1732
1813
  async dropTable() {
1733
1814
  return this.dropTableByName(this.resolveTableName());
1734
1815
  }
1816
+ async copyFromJsonColumn(source, targets) {
1817
+ const temporal = targets.find((t) => mysqlTemporalFsp(t.field) !== void 0);
1818
+ if (temporal) throw new Error(`"${temporal.column}" is a TIMESTAMP / DATETIME column — its epoch-ms JSON values cannot be copied into it`);
1819
+ const sql = (0, _atscript_db_sql_tools.buildJsonColumnCopy)(mysqlDialect, this.resolveTableName(), source, targets);
1820
+ await this._withStrictSession(async (conn) => {
1821
+ this._log(sql);
1822
+ await conn.exec(sql);
1823
+ });
1824
+ }
1825
+ async jsonifyTextColumn(column) {
1826
+ const sql = (0, _atscript_db_sql_tools.buildJsonifyText)(mysqlDialect, this.resolveTableName(), column);
1827
+ this._log(sql);
1828
+ await this._exec().exec(sql);
1829
+ }
1735
1830
  async dropColumns(columns) {
1736
1831
  const tableName = this.resolveTableName();
1737
1832
  const drops = columns.map((col) => `DROP COLUMN ${qi(col)}`).join(", ");
@@ -2162,6 +2257,41 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
2162
2257
  return (0, _atscript_db_sql_tools.buildGeoSearchSelect)(mysqlDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window, ctx.controls);
2163
2258
  }
2164
2259
  /**
2260
+ * Migrates an epoch-ms number column to a native `TIMESTAMP` / `DATETIME`
2261
+ * via a temp column: each value becomes the UTC wall time the adapter would
2262
+ * write for it (`formatValue`), truncated to the column's precision. A value
2263
+ * the type cannot hold (before 1970 / after 2038 for `TIMESTAMP`) — or a
2264
+ * column the drop is refused for (an index it would leave with duplicates,
2265
+ * a foreign key) — fails the sync with the original column untouched (the
2266
+ * temp column is dropped again). The column keeps its position. It must
2267
+ * not be keyed (dropping it would shrink the key): `syncColumns` refuses a
2268
+ * column keyed before and after, and defers one leaving the key to
2269
+ * `rebuildPrimaryKey`. In a text column (`fromText`) only a numeric value is
2270
+ * epoch ms; other text is read as a datetime, as a plain `MODIFY` would.
2271
+ */
2272
+ async _migrateEpochColumnToTemporal(conn, tableName, field, sqlType, fromText = false) {
2273
+ const col = qi(field.physicalName);
2274
+ const tmp = qi(`${field.physicalName}__ts_mig`);
2275
+ const quotedTable = quoteTableName(tableName);
2276
+ const digits = Math.min(mysqlTemporalFsp(field) ?? 0, 3);
2277
+ const epoch = `DATE_ADD(CAST('1970-01-01 00:00:00' AS DATETIME(6)), INTERVAL ${`FLOOR(${col} / ${10 ** (3 - digits)}) * ${10 ** (6 - digits)}`} MICROSECOND)`;
2278
+ const value = fromText ? `CASE WHEN ${col} REGEXP '^[0-9]+([.][0-9]+)?$' THEN ${epoch} ELSE CAST(${col} AS DATETIME(6)) END` : epoch;
2279
+ const run = async (ddl) => {
2280
+ this._log(ddl);
2281
+ await conn.exec(ddl);
2282
+ };
2283
+ await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${tmp} ${sqlType} NULL AFTER ${col}`);
2284
+ try {
2285
+ await run(`UPDATE ${quotedTable} SET ${tmp} = ${value} WHERE ${col} IS NOT NULL`);
2286
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${col}`);
2287
+ } catch (error) {
2288
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${tmp}`).catch(() => void 0);
2289
+ throw error;
2290
+ }
2291
+ await run(`ALTER TABLE ${quotedTable} RENAME COLUMN ${tmp} TO ${col}`);
2292
+ await run(`ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`);
2293
+ }
2294
+ /**
2165
2295
  * Migrates a v1 JSON `[lng, lat]` column to native `POINT SRID 4326` via a
2166
2296
  * temp column (MySQL has no JSON→geometry cast for MODIFY COLUMN).
2167
2297
  */
@@ -2228,8 +2358,14 @@ function sanitizeParams(params) {
2228
2358
  *
2229
2359
  * - TIMESTAMP/DATETIME → epoch milliseconds (number) instead of Date objects
2230
2360
  * - DECIMAL/NEWDECIMAL → number instead of string
2361
+ * - JSON → its text (since 0.1.155), as MariaDB's `LONGTEXT` JSON and SQLite
2362
+ * return it: the relational mapper parses JSON text, so a JSON string value
2363
+ * is not mistaken for JSON text. Done here as well as with `jsonStrings`:
2364
+ * mysql2 caches compiled row parsers per process without keying them by
2365
+ * `jsonStrings`, so another pool's parser could otherwise be reused.
2231
2366
  */
2232
2367
  function atscriptTypeCast(field, next) {
2368
+ if (field.type === "JSON") return field.string("utf8");
2233
2369
  if (field.type === "TIMESTAMP" || field.type === "DATETIME") {
2234
2370
  const str = field.string();
2235
2371
  if (str === null) return null;
@@ -2312,6 +2448,7 @@ var Mysql2Driver = class {
2312
2448
  timezone: "+00:00",
2313
2449
  supportBigNumbers: true,
2314
2450
  bigNumberStrings: false,
2451
+ jsonStrings: true,
2315
2452
  typeCast: atscriptTypeCast
2316
2453
  });
2317
2454
  else this.pool = mysql.createPool({
@@ -2319,6 +2456,7 @@ var Mysql2Driver = class {
2319
2456
  timezone: "+00:00",
2320
2457
  supportBigNumbers: true,
2321
2458
  bigNumberStrings: false,
2459
+ jsonStrings: true,
2322
2460
  typeCast: atscriptTypeCast
2323
2461
  });
2324
2462
  if (strict) ensureStrict(this.pool);
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TJsonCopyTarget, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TMetadataMap } from "@atscript/typescript/utils";
3
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
4
  import { TFilterVisitorOptions, TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
@@ -112,6 +112,18 @@ declare class MysqlAdapter extends BaseDbAdapter {
112
112
  private _collation;
113
113
  private _autoIncrementStart?;
114
114
  private _incrementFields;
115
+ /**
116
+ * Changes of columns leaving a pending primary key that `syncColumns` left
117
+ * to the next `rebuildPrimaryKey` (re-declared in its swap statement).
118
+ */
119
+ private _leavingKeyColumns;
120
+ /**
121
+ * Columns leaving a pending primary key that also convert from epoch ms to
122
+ * TIMESTAMP / DATETIME: the temp-column conversion drops the column, which
123
+ * a key column cannot be — the next `rebuildPrimaryKey` converts them
124
+ * after its swap.
125
+ */
126
+ private _epochAfterRekey;
115
127
  private _onUpdateFields;
116
128
  /** Whether the connected MySQL instance supports native VECTOR type (MySQL 9.0+). */
117
129
  private _supportsVector;
@@ -386,7 +398,10 @@ declare class MysqlAdapter extends BaseDbAdapter {
386
398
  private _tableOptions;
387
399
  /** The shared column-definition context for ADD/MODIFY statements. */
388
400
  private _columnCtx;
401
+ /** The quoted table `tableName` of the adapter's schema, or the adapter's own. */
402
+ private _quotedTable;
389
403
  hasRows(tableName?: string): Promise<boolean>;
404
+ countKeyViolations(columns: readonly string[], tableName?: string): Promise<number>;
390
405
  /** Live foreign keys referencing `tableName` (any table of the schema). */
391
406
  getReferencingForeignKeys(tableName: string): Promise<TReferencingForeignKey[]>;
392
407
  getObjectKind(name: string): Promise<TDbObjectKind | undefined>;
@@ -399,7 +414,8 @@ declare class MysqlAdapter extends BaseDbAdapter {
399
414
  * NOT NULL) together with the key swap — so an AUTO_INCREMENT column never
400
415
  * exists without a key (ER 1075), in safe mode or otherwise. A demoted key
401
416
  * column loses AUTO_INCREMENT in the same statement (pre-flight guarantees
402
- * its model no longer declares increment). Called on an empty table only.
417
+ * its model no longer declares increment). Called on an empty table or one
418
+ * whose rows satisfy the new key (`countKeyViolations`).
403
419
  */
404
420
  rebuildPrimaryKey(change: TPrimaryKeyChange): Promise<void>;
405
421
  private _ensureView;
@@ -410,6 +426,8 @@ declare class MysqlAdapter extends BaseDbAdapter {
410
426
  syncColumns(diff: TColumnDiff): Promise<TSyncColumnResult>;
411
427
  recreateTable(): Promise<void>;
412
428
  dropTable(): Promise<void>;
429
+ copyFromJsonColumn(source: string, targets: readonly TJsonCopyTarget[]): Promise<void>;
430
+ jsonifyTextColumn(column: string): Promise<void>;
413
431
  dropColumns(columns: string[]): Promise<void>;
414
432
  dropIndexesForColumns(columns: string[]): Promise<void>;
415
433
  dropTableByName(tableName: string): Promise<void>;
@@ -467,6 +485,20 @@ declare class MysqlAdapter extends BaseDbAdapter {
467
485
  /** Resolves the shared parts of a geo search once (column, filter, distance, window). */
468
486
  private _prepareGeoSearch;
469
487
  private _buildGeoSearchSelect;
488
+ /**
489
+ * Migrates an epoch-ms number column to a native `TIMESTAMP` / `DATETIME`
490
+ * via a temp column: each value becomes the UTC wall time the adapter would
491
+ * write for it (`formatValue`), truncated to the column's precision. A value
492
+ * the type cannot hold (before 1970 / after 2038 for `TIMESTAMP`) — or a
493
+ * column the drop is refused for (an index it would leave with duplicates,
494
+ * a foreign key) — fails the sync with the original column untouched (the
495
+ * temp column is dropped again). The column keeps its position. It must
496
+ * not be keyed (dropping it would shrink the key): `syncColumns` refuses a
497
+ * column keyed before and after, and defers one leaving the key to
498
+ * `rebuildPrimaryKey`. In a text column (`fromText`) only a numeric value is
499
+ * epoch ms; other text is read as a datetime, as a plain `MODIFY` would.
500
+ */
501
+ private _migrateEpochColumnToTemporal;
470
502
  /**
471
503
  * Migrates a v1 JSON `[lng, lat]` column to native `POINT SRID 4326` via a
472
504
  * temp column (MySQL has no JSON→geometry cast for MODIFY COLUMN).
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair, TViewCapability } from "@atscript/db";
1
+ import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreOptions, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TJsonCopyTarget, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair, TViewCapability } from "@atscript/db";
2
2
  import { TFilterVisitorOptions, TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
3
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
4
  import { TMetadataMap } from "@atscript/typescript/utils";
@@ -112,6 +112,18 @@ declare class MysqlAdapter extends BaseDbAdapter {
112
112
  private _collation;
113
113
  private _autoIncrementStart?;
114
114
  private _incrementFields;
115
+ /**
116
+ * Changes of columns leaving a pending primary key that `syncColumns` left
117
+ * to the next `rebuildPrimaryKey` (re-declared in its swap statement).
118
+ */
119
+ private _leavingKeyColumns;
120
+ /**
121
+ * Columns leaving a pending primary key that also convert from epoch ms to
122
+ * TIMESTAMP / DATETIME: the temp-column conversion drops the column, which
123
+ * a key column cannot be — the next `rebuildPrimaryKey` converts them
124
+ * after its swap.
125
+ */
126
+ private _epochAfterRekey;
115
127
  private _onUpdateFields;
116
128
  /** Whether the connected MySQL instance supports native VECTOR type (MySQL 9.0+). */
117
129
  private _supportsVector;
@@ -386,7 +398,10 @@ declare class MysqlAdapter extends BaseDbAdapter {
386
398
  private _tableOptions;
387
399
  /** The shared column-definition context for ADD/MODIFY statements. */
388
400
  private _columnCtx;
401
+ /** The quoted table `tableName` of the adapter's schema, or the adapter's own. */
402
+ private _quotedTable;
389
403
  hasRows(tableName?: string): Promise<boolean>;
404
+ countKeyViolations(columns: readonly string[], tableName?: string): Promise<number>;
390
405
  /** Live foreign keys referencing `tableName` (any table of the schema). */
391
406
  getReferencingForeignKeys(tableName: string): Promise<TReferencingForeignKey[]>;
392
407
  getObjectKind(name: string): Promise<TDbObjectKind | undefined>;
@@ -399,7 +414,8 @@ declare class MysqlAdapter extends BaseDbAdapter {
399
414
  * NOT NULL) together with the key swap — so an AUTO_INCREMENT column never
400
415
  * exists without a key (ER 1075), in safe mode or otherwise. A demoted key
401
416
  * column loses AUTO_INCREMENT in the same statement (pre-flight guarantees
402
- * its model no longer declares increment). Called on an empty table only.
417
+ * its model no longer declares increment). Called on an empty table or one
418
+ * whose rows satisfy the new key (`countKeyViolations`).
403
419
  */
404
420
  rebuildPrimaryKey(change: TPrimaryKeyChange): Promise<void>;
405
421
  private _ensureView;
@@ -410,6 +426,8 @@ declare class MysqlAdapter extends BaseDbAdapter {
410
426
  syncColumns(diff: TColumnDiff): Promise<TSyncColumnResult>;
411
427
  recreateTable(): Promise<void>;
412
428
  dropTable(): Promise<void>;
429
+ copyFromJsonColumn(source: string, targets: readonly TJsonCopyTarget[]): Promise<void>;
430
+ jsonifyTextColumn(column: string): Promise<void>;
413
431
  dropColumns(columns: string[]): Promise<void>;
414
432
  dropIndexesForColumns(columns: string[]): Promise<void>;
415
433
  dropTableByName(tableName: string): Promise<void>;
@@ -467,6 +485,20 @@ declare class MysqlAdapter extends BaseDbAdapter {
467
485
  /** Resolves the shared parts of a geo search once (column, filter, distance, window). */
468
486
  private _prepareGeoSearch;
469
487
  private _buildGeoSearchSelect;
488
+ /**
489
+ * Migrates an epoch-ms number column to a native `TIMESTAMP` / `DATETIME`
490
+ * via a temp column: each value becomes the UTC wall time the adapter would
491
+ * write for it (`formatValue`), truncated to the column's precision. A value
492
+ * the type cannot hold (before 1970 / after 2038 for `TIMESTAMP`) — or a
493
+ * column the drop is refused for (an index it would leave with duplicates,
494
+ * a foreign key) — fails the sync with the original column untouched (the
495
+ * temp column is dropped again). The column keeps its position. It must
496
+ * not be keyed (dropping it would shrink the key): `syncColumns` refuses a
497
+ * column keyed before and after, and defers one leaving the key to
498
+ * `rebuildPrimaryKey`. In a text column (`fromText`) only a numeric value is
499
+ * epoch ms; other text is read as a datetime, as a plain `MODIFY` would.
500
+ */
501
+ private _migrateEpochColumnToTemporal;
470
502
  /**
471
503
  * Migrates a v1 JSON `[lng, lat]` column to native `POINT SRID 4326` via a
472
504
  * temp column (MySQL has no JSON→geometry cast for MODIFY COLUMN).
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, NoopLogger, bucketTimeZoneUnavailable, containsRelationFilter, defaultFulltextIndex, describeFulltext, fkColumns, forEachResolvedRelation, isConflict, searchTermInteger, splitFulltextFields, uniqueKeyTuple, vectorIndexNotFoundMessage } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { EMPTY_OR, SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, foreignKeySql, fromSourceHint, fromSourceSql, geoWindowFromControls, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource, viewReadSource } from "@atscript/db-sql-tools";
3
+ import { EMPTY_OR, SEARCH_SOURCE_ALIAS, arithOverflowError, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildJsonColumnCopy, buildJsonifyText, buildKeyViolationCount, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, chunkInsertRows, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, foreignKeySql, fromSourceHint, fromSourceSql, geoWindowFromControls, jsonDollarPath, mapQueryErrors, normalizeGeoPointValue, numericOutOfRangeError, orFragment, parseRegexString, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue, vectorDistanceSource, viewReadSource } from "@atscript/db-sql-tools";
4
4
  import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
5
5
  //#region src/sql-builder.ts
6
6
  /** TEXT / BLOB families (`TINYTEXT` … `LONGBLOB`). */
@@ -182,6 +182,15 @@ const mysqlDialect = {
182
182
  calendarBucket: mysqlCalendarBucket,
183
183
  bucketAliasInHaving: true,
184
184
  jsonExtract: mysqlJsonExtract,
185
+ jsonExtractText: (quotedCol, path) => {
186
+ const e = `JSON_EXTRACT(${quotedCol}, ${jsonDollarPath(path)})`;
187
+ return `CASE WHEN JSON_TYPE(${e}) = 'NULL' THEN NULL ELSE JSON_UNQUOTE(${e}) END`;
188
+ },
189
+ jsonExtractValue: (quotedCol, path) => {
190
+ const e = `JSON_EXTRACT(${quotedCol}, ${jsonDollarPath(path)})`;
191
+ return `CASE WHEN JSON_TYPE(${e}) = 'NULL' THEN NULL ELSE ${e} END`;
192
+ },
193
+ jsonFromText: (quotedCol) => `IF(JSON_VALID(${quotedCol}), ${quotedCol}, JSON_QUOTE(${quotedCol}))`,
185
194
  castDouble: (expr) => `CAST(${expr} AS DOUBLE)`,
186
195
  createViewPrefix: "CREATE OR REPLACE VIEW"
187
196
  };
@@ -568,6 +577,20 @@ function epochMsToUtcDatetime(ms, fsp = 0) {
568
577
  */
569
578
  const TZ_PROBE_SQL = "SELECT CONVERT_TZ('2040-06-01 12:00:00', '+00:00', ?) IS NULL AS missing, TIMESTAMPDIFF(MINUTE, '2040-06-01 12:00:00', CONVERT_TZ('2040-06-01 12:00:00', '+00:00', '+01:00')) AS shift";
570
579
  const isZero = (v) => v === 0 || v === "0";
580
+ /** A live numeric column type (`INFORMATION_SCHEMA.COLUMNS.COLUMN_TYPE`). */
581
+ const NUMERIC_SQL_TYPE = /^(double|float|real|decimal|numeric|bigint|int|integer|mediumint|smallint|tinyint)\b/i;
582
+ /** A live text column type — e.g. the column atscript-db <= 0.1.154 created for a `T | null` union. */
583
+ const TEXT_SQL_TYPE = /^(tinytext|text|mediumtext|longtext|varchar|char)\b/i;
584
+ /**
585
+ * Whether the type change of `field` from the live `existingType` converts
586
+ * epoch ms to a native TIMESTAMP / DATETIME (through a temp column, see
587
+ * `_migrateEpochColumnToTemporal`); `fromText`: the live column is text.
588
+ */
589
+ function epochSource(field, existingType) {
590
+ if (!isMysqlTimestampColumn(field)) return void 0;
591
+ const fromText = TEXT_SQL_TYPE.test(existingType);
592
+ return fromText || NUMERIC_SQL_TYPE.test(existingType) ? { fromText } : void 0;
593
+ }
571
594
  /** Drivers whose session `sql_mode` was already checked for strictness (see `_warnNonStrictMode`). */
572
595
  const nonStrictChecked = /* @__PURE__ */ new WeakSet();
573
596
  /**
@@ -601,6 +624,18 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
601
624
  _collation = "utf8mb4_unicode_ci";
602
625
  _autoIncrementStart;
603
626
  _incrementFields = /* @__PURE__ */ new Set();
627
+ /**
628
+ * Changes of columns leaving a pending primary key that `syncColumns` left
629
+ * to the next `rebuildPrimaryKey` (re-declared in its swap statement).
630
+ */
631
+ _leavingKeyColumns = /* @__PURE__ */ new Map();
632
+ /**
633
+ * Columns leaving a pending primary key that also convert from epoch ms to
634
+ * TIMESTAMP / DATETIME: the temp-column conversion drops the column, which
635
+ * a key column cannot be — the next `rebuildPrimaryKey` converts them
636
+ * after its swap.
637
+ */
638
+ _epochAfterRekey = /* @__PURE__ */ new Map();
604
639
  _onUpdateFields = /* @__PURE__ */ new Map();
605
640
  /** Whether the connected MySQL instance supports native VECTOR type (MySQL 9.0+). */
606
641
  _supportsVector;
@@ -1519,12 +1554,22 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1519
1554
  purpose
1520
1555
  };
1521
1556
  }
1557
+ /** The quoted table `tableName` of the adapter's schema, or the adapter's own. */
1558
+ _quotedTable(tableName) {
1559
+ return quoteTableName(tableName ? this._schema ? `${this._schema}.${tableName}` : tableName : this.resolveTableName());
1560
+ }
1522
1561
  async hasRows(tableName) {
1523
- const sql = `SELECT EXISTS(SELECT 1 FROM ${tableName ? quoteTableName(this._schema ? `${this._schema}.${tableName}` : tableName) : quoteTableName(this.resolveTableName())}) AS present`;
1562
+ const sql = `SELECT EXISTS(SELECT 1 FROM ${this._quotedTable(tableName)}) AS present`;
1524
1563
  this._log(sql);
1525
1564
  const row = await this._exec().get(sql, []);
1526
1565
  return Boolean(Number(row?.present ?? 0));
1527
1566
  }
1567
+ async countKeyViolations(columns, tableName) {
1568
+ const sql = buildKeyViolationCount(mysqlDialect, this._quotedTable(tableName), columns);
1569
+ this._log(sql);
1570
+ const row = await this._exec().get(sql, []);
1571
+ return Number(row?.violations ?? 0);
1572
+ }
1528
1573
  /** Live foreign keys referencing `tableName` (any table of the schema). */
1529
1574
  async getReferencingForeignKeys(tableName) {
1530
1575
  const rows = await this._exec().all(`SELECT TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_COLUMN_NAME
@@ -1563,7 +1608,8 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1563
1608
  * NOT NULL) together with the key swap — so an AUTO_INCREMENT column never
1564
1609
  * exists without a key (ER 1075), in safe mode or otherwise. A demoted key
1565
1610
  * column loses AUTO_INCREMENT in the same statement (pre-flight guarantees
1566
- * its model no longer declares increment). Called on an empty table only.
1611
+ * its model no longer declares increment). Called on an empty table or one
1612
+ * whose rows satisfy the new key (`countKeyViolations`).
1567
1613
  */
1568
1614
  async rebuildPrimaryKey(change) {
1569
1615
  const fields = new Map(this._table.fieldDescriptors.map((f) => [f.physicalName, f]));
@@ -1574,6 +1620,9 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1574
1620
  if (field) clauses.push(`MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`);
1575
1621
  }
1576
1622
  if (change.from.length > 0) {
1623
+ const leaving = this._leavingKeyColumns;
1624
+ this._leavingKeyColumns = /* @__PURE__ */ new Map();
1625
+ for (const field of leaving.values()) if (!to.has(field.physicalName) && !this._epochAfterRekey.has(field.physicalName)) clauses.push(`MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`);
1577
1626
  const placeholders = change.from.map(() => "?").join(", ");
1578
1627
  const live = await this._exec().all(`SELECT COLUMN_NAME, COLUMN_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS
1579
1628
  WHERE TABLE_NAME = ? AND TABLE_SCHEMA = COALESCE(?, DATABASE())
@@ -1583,7 +1632,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1583
1632
  ...change.from
1584
1633
  ]);
1585
1634
  for (const col of live) {
1586
- if (to.has(col.COLUMN_NAME) || !/auto_increment/i.test(col.EXTRA ?? "")) continue;
1635
+ if (to.has(col.COLUMN_NAME) || leaving.has(col.COLUMN_NAME) || !/auto_increment/i.test(col.EXTRA ?? "")) continue;
1587
1636
  const field = fields.get(col.COLUMN_NAME);
1588
1637
  const def = field ? buildColumnDefinition(field, this._columnCtx("modify")).def : `${qi(col.COLUMN_NAME)} ${col.COLUMN_TYPE.toUpperCase()} NOT NULL`;
1589
1638
  clauses.push(`MODIFY COLUMN ${def}`);
@@ -1591,10 +1640,18 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1591
1640
  clauses.push("DROP PRIMARY KEY");
1592
1641
  }
1593
1642
  if (change.to.length > 0) clauses.push(`ADD PRIMARY KEY (${change.to.map((c) => qi(c)).join(", ")})`);
1594
- if (clauses.length === 0) return;
1595
- const ddl = `ALTER TABLE ${quoteTableName(this.resolveTableName())} ${clauses.join(", ")}`;
1596
- this._log(ddl);
1597
- await this._withStrictSession((conn) => conn.exec(ddl));
1643
+ const epochAfter = this._epochAfterRekey;
1644
+ this._epochAfterRekey = /* @__PURE__ */ new Map();
1645
+ if (clauses.length === 0 && epochAfter.size === 0) return;
1646
+ const tableName = this.resolveTableName();
1647
+ const ddl = `ALTER TABLE ${quoteTableName(tableName)} ${clauses.join(", ")}`;
1648
+ await this._withStrictSession(async (conn) => {
1649
+ if (clauses.length > 0) {
1650
+ this._log(ddl);
1651
+ await conn.exec(ddl);
1652
+ }
1653
+ for (const { field, fromText } of epochAfter.values()) await this._migrateEpochColumnToTemporal(conn, tableName, field, this.typeMapper(field), fromText);
1654
+ });
1598
1655
  }
1599
1656
  async _ensureView() {
1600
1657
  const view = this._table;
@@ -1634,6 +1691,13 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1634
1691
  const tableName = this.resolveTableName();
1635
1692
  const added = [];
1636
1693
  const renamed = [];
1694
+ const keyChange = diff.primaryKeyChanged;
1695
+ const entering = new Set(keyChange?.to ?? []);
1696
+ const leaving = new Set(keyChange?.from.filter((c) => !entering.has(c)) ?? []);
1697
+ for (const { field, existingType } of diff.typeChanged ?? []) {
1698
+ const name = field.physicalName;
1699
+ if ((keyChange ? keyChange.from.includes(name) && entering.has(name) : field.isPrimaryKey) && epochSource(field, existingType) !== void 0) throw new Error(`Cannot convert primary-key column "${name}" from epoch milliseconds to ${this.typeMapper(field)} — migrate it manually.`);
1700
+ }
1637
1701
  for (const { field, oldName } of diff.renamed ?? []) {
1638
1702
  const ddl = `ALTER TABLE ${quoteTableName(tableName)} RENAME COLUMN ${qi(oldName)} TO ${qi(field.physicalName)}`;
1639
1703
  this._log(ddl);
@@ -1653,10 +1717,27 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1653
1717
  }
1654
1718
  added.push(field.physicalName);
1655
1719
  }
1656
- const enteringKey = new Set(diff.primaryKeyChanged?.to ?? []);
1720
+ this._leavingKeyColumns = /* @__PURE__ */ new Map();
1721
+ this._epochAfterRekey = /* @__PURE__ */ new Map();
1722
+ const rekeyed = (field) => {
1723
+ if (leaving.has(field.physicalName)) {
1724
+ this._leavingKeyColumns.set(field.physicalName, field);
1725
+ return true;
1726
+ }
1727
+ return entering.has(field.physicalName);
1728
+ };
1657
1729
  const modified = /* @__PURE__ */ new Map();
1658
- for (const { field } of diff.typeChanged ?? []) {
1659
- if (enteringKey.has(field.physicalName)) continue;
1730
+ for (const { field, existingType } of diff.typeChanged ?? []) {
1731
+ const epoch = epochSource(field, existingType);
1732
+ if (epoch) {
1733
+ if (leaving.has(field.physicalName)) this._epochAfterRekey.set(field.physicalName, {
1734
+ field,
1735
+ fromText: epoch.fromText
1736
+ });
1737
+ else await this._migrateEpochColumnToTemporal(conn, tableName, field, this.typeMapper(field), epoch.fromText);
1738
+ continue;
1739
+ }
1740
+ if (rekeyed(field)) continue;
1660
1741
  const sqlType = this.typeMapper(field);
1661
1742
  if (field.isGeoPoint && !field.encrypted && sqlType.startsWith("POINT")) {
1662
1743
  await this._migrateJsonColumnToPoint(conn, tableName, field);
@@ -1665,7 +1746,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1665
1746
  modified.set(field.physicalName, field);
1666
1747
  }
1667
1748
  for (const { field } of diff.nullableChanged ?? []) {
1668
- if (enteringKey.has(field.physicalName)) continue;
1749
+ if (rekeyed(field)) continue;
1669
1750
  if (!field.optional) {
1670
1751
  const sqlType = this.typeMapper(field);
1671
1752
  const fallback = field.defaultValue?.kind === "value" ? mysqlDefaultLiteral(sqlType, field.designType, field.defaultValue.value) : mysqlTypeDefault(sqlType, field);
@@ -1675,7 +1756,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1675
1756
  }
1676
1757
  modified.set(field.physicalName, field);
1677
1758
  }
1678
- for (const { field } of diff.defaultChanged ?? []) if (!enteringKey.has(field.physicalName)) modified.set(field.physicalName, field);
1759
+ for (const { field } of diff.defaultChanged ?? []) if (!rekeyed(field)) modified.set(field.physicalName, field);
1679
1760
  for (const field of modified.values()) {
1680
1761
  const ddl = `ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`;
1681
1762
  this._log(ddl);
@@ -1731,6 +1812,20 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
1731
1812
  async dropTable() {
1732
1813
  return this.dropTableByName(this.resolveTableName());
1733
1814
  }
1815
+ async copyFromJsonColumn(source, targets) {
1816
+ const temporal = targets.find((t) => mysqlTemporalFsp(t.field) !== void 0);
1817
+ if (temporal) throw new Error(`"${temporal.column}" is a TIMESTAMP / DATETIME column — its epoch-ms JSON values cannot be copied into it`);
1818
+ const sql = buildJsonColumnCopy(mysqlDialect, this.resolveTableName(), source, targets);
1819
+ await this._withStrictSession(async (conn) => {
1820
+ this._log(sql);
1821
+ await conn.exec(sql);
1822
+ });
1823
+ }
1824
+ async jsonifyTextColumn(column) {
1825
+ const sql = buildJsonifyText(mysqlDialect, this.resolveTableName(), column);
1826
+ this._log(sql);
1827
+ await this._exec().exec(sql);
1828
+ }
1734
1829
  async dropColumns(columns) {
1735
1830
  const tableName = this.resolveTableName();
1736
1831
  const drops = columns.map((col) => `DROP COLUMN ${qi(col)}`).join(", ");
@@ -2161,6 +2256,41 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
2161
2256
  return buildGeoSearchSelect(mysqlDialect, ctx.tableName, ctx.where, ctx.dist, ctx.window, ctx.controls);
2162
2257
  }
2163
2258
  /**
2259
+ * Migrates an epoch-ms number column to a native `TIMESTAMP` / `DATETIME`
2260
+ * via a temp column: each value becomes the UTC wall time the adapter would
2261
+ * write for it (`formatValue`), truncated to the column's precision. A value
2262
+ * the type cannot hold (before 1970 / after 2038 for `TIMESTAMP`) — or a
2263
+ * column the drop is refused for (an index it would leave with duplicates,
2264
+ * a foreign key) — fails the sync with the original column untouched (the
2265
+ * temp column is dropped again). The column keeps its position. It must
2266
+ * not be keyed (dropping it would shrink the key): `syncColumns` refuses a
2267
+ * column keyed before and after, and defers one leaving the key to
2268
+ * `rebuildPrimaryKey`. In a text column (`fromText`) only a numeric value is
2269
+ * epoch ms; other text is read as a datetime, as a plain `MODIFY` would.
2270
+ */
2271
+ async _migrateEpochColumnToTemporal(conn, tableName, field, sqlType, fromText = false) {
2272
+ const col = qi(field.physicalName);
2273
+ const tmp = qi(`${field.physicalName}__ts_mig`);
2274
+ const quotedTable = quoteTableName(tableName);
2275
+ const digits = Math.min(mysqlTemporalFsp(field) ?? 0, 3);
2276
+ const epoch = `DATE_ADD(CAST('1970-01-01 00:00:00' AS DATETIME(6)), INTERVAL ${`FLOOR(${col} / ${10 ** (3 - digits)}) * ${10 ** (6 - digits)}`} MICROSECOND)`;
2277
+ const value = fromText ? `CASE WHEN ${col} REGEXP '^[0-9]+([.][0-9]+)?$' THEN ${epoch} ELSE CAST(${col} AS DATETIME(6)) END` : epoch;
2278
+ const run = async (ddl) => {
2279
+ this._log(ddl);
2280
+ await conn.exec(ddl);
2281
+ };
2282
+ await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${tmp} ${sqlType} NULL AFTER ${col}`);
2283
+ try {
2284
+ await run(`UPDATE ${quotedTable} SET ${tmp} = ${value} WHERE ${col} IS NOT NULL`);
2285
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${col}`);
2286
+ } catch (error) {
2287
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${tmp}`).catch(() => void 0);
2288
+ throw error;
2289
+ }
2290
+ await run(`ALTER TABLE ${quotedTable} RENAME COLUMN ${tmp} TO ${col}`);
2291
+ await run(`ALTER TABLE ${quotedTable} MODIFY COLUMN ${buildColumnDefinition(field, this._columnCtx("modify")).def}`);
2292
+ }
2293
+ /**
2164
2294
  * Migrates a v1 JSON `[lng, lat]` column to native `POINT SRID 4326` via a
2165
2295
  * temp column (MySQL has no JSON→geometry cast for MODIFY COLUMN).
2166
2296
  */
@@ -2227,8 +2357,14 @@ function sanitizeParams(params) {
2227
2357
  *
2228
2358
  * - TIMESTAMP/DATETIME → epoch milliseconds (number) instead of Date objects
2229
2359
  * - DECIMAL/NEWDECIMAL → number instead of string
2360
+ * - JSON → its text (since 0.1.155), as MariaDB's `LONGTEXT` JSON and SQLite
2361
+ * return it: the relational mapper parses JSON text, so a JSON string value
2362
+ * is not mistaken for JSON text. Done here as well as with `jsonStrings`:
2363
+ * mysql2 caches compiled row parsers per process without keying them by
2364
+ * `jsonStrings`, so another pool's parser could otherwise be reused.
2230
2365
  */
2231
2366
  function atscriptTypeCast(field, next) {
2367
+ if (field.type === "JSON") return field.string("utf8");
2232
2368
  if (field.type === "TIMESTAMP" || field.type === "DATETIME") {
2233
2369
  const str = field.string();
2234
2370
  if (str === null) return null;
@@ -2311,6 +2447,7 @@ var Mysql2Driver = class {
2311
2447
  timezone: "+00:00",
2312
2448
  supportBigNumbers: true,
2313
2449
  bigNumberStrings: false,
2450
+ jsonStrings: true,
2314
2451
  typeCast: atscriptTypeCast
2315
2452
  });
2316
2453
  else this.pool = mysql.createPool({
@@ -2318,6 +2455,7 @@ var Mysql2Driver = class {
2318
2455
  timezone: "+00:00",
2319
2456
  supportBigNumbers: true,
2320
2457
  bigNumberStrings: false,
2458
+ jsonStrings: true,
2321
2459
  typeCast: atscriptTypeCast
2322
2460
  });
2323
2461
  if (strict) ensureStrict(this.pool);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mysql",
3
- "version": "0.1.154",
3
+ "version": "0.1.155",
4
4
  "description": "MySQL adapter for @atscript/db with mysql2 driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -41,19 +41,19 @@
41
41
  "access": "public"
42
42
  },
43
43
  "devDependencies": {
44
- "@atscript/core": "^0.1.103",
45
- "@atscript/typescript": "^0.1.103",
44
+ "@atscript/core": "^0.1.105",
45
+ "@atscript/typescript": "^0.1.105",
46
46
  "@uniqu/core": "^0.1.15",
47
47
  "mysql2": "^3.11.0",
48
- "unplugin-atscript": "^0.1.103"
48
+ "unplugin-atscript": "^0.1.105"
49
49
  },
50
50
  "peerDependencies": {
51
- "@atscript/core": "^0.1.103",
52
- "@atscript/typescript": "^0.1.103",
51
+ "@atscript/core": "^0.1.105",
52
+ "@atscript/typescript": "^0.1.105",
53
53
  "@uniqu/core": "^0.1.15",
54
54
  "mysql2": ">=3.0.0",
55
- "@atscript/db": "^0.1.154",
56
- "@atscript/db-sql-tools": "^0.1.154"
55
+ "@atscript/db": "^0.1.155",
56
+ "@atscript/db-sql-tools": "^0.1.155"
57
57
  },
58
58
  "peerDependenciesMeta": {
59
59
  "mysql2": {