@atscript/db-mysql 0.1.131 → 0.1.132

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
@@ -2,6 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _atscript_db = require("@atscript/db");
3
3
  let _atscript_db_agg = require("@atscript/db/agg");
4
4
  let _atscript_db_sql_tools = require("@atscript/db-sql-tools");
5
+ let _uniqu_core = require("@uniqu/core");
5
6
  //#region src/sql-builder.ts
6
7
  /** TEXT / BLOB families (`TINYTEXT` … `LONGBLOB`). */
7
8
  const TEXT_BLOB_RE = /^(TINY|MEDIUM|LONG)?(TEXT|BLOB)\b/i;
@@ -165,9 +166,74 @@ const mysqlDialect = {
165
166
  params: [...dist.params, circle.radius]
166
167
  };
167
168
  },
169
+ calendarBucket: mysqlCalendarBucket,
170
+ bucketAliasInHaving: true,
168
171
  createViewPrefix: "CREATE OR REPLACE VIEW"
169
172
  };
170
173
  /**
174
+ * Whether a field is stored as a native MySQL `TIMESTAMP`: a `number` with
175
+ * `@db.default.now`. The adapter writes such values as UTC
176
+ * `'YYYY-MM-DD HH:MM:SS'` strings and reads them back as epoch ms; every other
177
+ * numeric timestamp is a DOUBLE / BIGINT epoch-ms column.
178
+ */
179
+ function isMysqlTimestampColumn(fd) {
180
+ return fd.designType === "number" && fd.defaultValue?.kind === "fn" && fd.defaultValue.fn === "now";
181
+ }
182
+ /** `'1970-01-01 00:00:00'` as a DATETIME: the base for epoch arithmetic free of the session zone. */
183
+ const MYSQL_EPOCH = "CAST('1970-01-01 00:00:00' AS DATETIME)";
184
+ /**
185
+ * Calendar-bucket label: TEXT `'YYYY-MM-DD'` — the local date of the
186
+ * bucket's first day in `b.tz` — or NULL for a NULL source or one outside
187
+ * `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
188
+ *
189
+ * The source's UTC wall time `U` (a DATETIME) depends on storage:
190
+ * - DOUBLE / BIGINT epoch ms: `epoch + INTERVAL FLOOR(col / 1000) SECOND`
191
+ * (never `FROM_UNIXTIME`, which converts to the session zone);
192
+ * - native `TIMESTAMP` ({@link isMysqlTimestampColumn}): `CAST(col AS DATETIME)`
193
+ * — the session-zone rendering, which is exactly the UTC string the adapter
194
+ * wrote and parses back on read, so the label agrees with the value reads
195
+ * return whatever the server's session zone is (`UNIX_TIMESTAMP(col)` would
196
+ * agree only under a UTC session zone).
197
+ *
198
+ * The local date is `DATE(CONVERT_TZ(U, '+00:00', '<tz>'))`, or `DATE(U)` for
199
+ * `UTC` (no time zone tables needed). Truncation is calendar arithmetic on
200
+ * that date. `CONVERT_TZ` returns NULL when the zone is unknown to the
201
+ * server and its input unchanged outside its range — `MysqlAdapter` probes
202
+ * each zone before running the query (`BUCKET_TZ_UNAVAILABLE`).
203
+ *
204
+ * Parameter-free (the zone is an inlined, charset-checked literal), so the
205
+ * SELECT and GROUP BY renderings are identical (`ONLY_FULL_GROUP_BY`).
206
+ */
207
+ function mysqlCalendarBucket(quotedCol, b) {
208
+ let utc;
209
+ let inRange;
210
+ if (isMysqlTimestampColumn(b.fd)) {
211
+ utc = `CAST(${quotedCol} AS DATETIME)`;
212
+ inRange = `${utc} >= '1970-01-02 00:00:00'`;
213
+ } else {
214
+ utc = `(${MYSQL_EPOCH} + INTERVAL FLOOR(${quotedCol} / 1000) SECOND)`;
215
+ inRange = `${quotedCol} >= ${_uniqu_core.BUCKET_MIN_INSTANT} AND ${quotedCol} < ${_uniqu_core.BUCKET_MAX_INSTANT}`;
216
+ }
217
+ const local = b.tz === "UTC" ? `DATE(${utc})` : `DATE(CONVERT_TZ(${utc}, '+00:00', ${(0, _atscript_db_sql_tools.sqlTimeZoneLiteral)(b.tz)}))`;
218
+ let first;
219
+ switch (b.unit) {
220
+ case "day":
221
+ first = local;
222
+ break;
223
+ case "week":
224
+ first = `DATE_SUB(${local}, INTERVAL ((WEEKDAY(${local}) + 1 - ${b.weekStartIso} + 7) % 7) DAY)`;
225
+ break;
226
+ case "month":
227
+ first = `DATE_SUB(${local}, INTERVAL DAYOFMONTH(${local}) - 1 DAY)`;
228
+ break;
229
+ case "quarter":
230
+ first = `(MAKEDATE(YEAR(${local}), 1) + INTERVAL (QUARTER(${local}) - 1) QUARTER)`;
231
+ break;
232
+ default: first = `MAKEDATE(YEAR(${local}), 1)`;
233
+ }
234
+ return `CASE WHEN ${inRange} THEN DATE_FORMAT(${first}, '%Y-%m-%d') END`;
235
+ }
236
+ /**
171
237
  * Spherical distance in meters from a POINT column to a query point.
172
238
  * Used as the `distExpr` of the shared geo search builder.
173
239
  */
@@ -210,6 +276,10 @@ function mysqlGeoValueToPoint(value) {
210
276
  function buildInsert(table, data) {
211
277
  return (0, _atscript_db_sql_tools.buildInsert)(mysqlDialect, table, data);
212
278
  }
279
+ /** Multi-row INSERT over `columns` (a missing column → `DEFAULT`). */
280
+ function buildInsertMany(table, rows, columns) {
281
+ return (0, _atscript_db_sql_tools.buildInsertMany)(mysqlDialect, table, rows, columns);
282
+ }
213
283
  /**
214
284
  * Builds a SELECT statement with optional sort, limit, offset, projection.
215
285
  */
@@ -291,7 +361,7 @@ function mysqlTypeFromField(field) {
291
361
  case "number":
292
362
  if (precision) return `DECIMAL(${precision.precision},${precision.scale})`;
293
363
  if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "increment") return unsigned ? "BIGINT UNSIGNED" : "BIGINT";
294
- if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "now") return "TIMESTAMP";
364
+ if (isMysqlTimestampColumn(field)) return "TIMESTAMP";
295
365
  if (tags?.has("int")) return intTypeFromTags(tags, unsigned);
296
366
  return "DOUBLE";
297
367
  case "integer": return intTypeFromTags(tags, unsigned);
@@ -385,6 +455,25 @@ function epochMsToUtcDatetime(ms) {
385
455
  return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
386
456
  }
387
457
  /**
458
+ * One statement answers both questions `CONVERT_TZ` never raises an error
459
+ * for: `missing` — the named zone (bound as `?`) converts to NULL, i.e. the
460
+ * time zone tables are not loaded or lack it; `shift` — a fixed `+01:00`
461
+ * conversion after 2038 is not applied (0 instead of 60 minutes), i.e. the
462
+ * server's `CONVERT_TZ` range ends in 2038 (before 8.0.28, or 32-bit).
463
+ *
464
+ * The range half deliberately uses a fixed offset, not the named zone: MySQL
465
+ * tz tables hold no transitions after 2037, so a zone such as `Europe/London`
466
+ * legitimately converts to +00:00 in June 2040 on a current server — an
467
+ * "unchanged" named-zone result is not evidence of a limited range.
468
+ */
469
+ 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";
470
+ /**
471
+ * Zones `CONVERT_TZ` is known to handle, per driver (adapters are per table
472
+ * and share a driver). Positives only: a failed zone is probed again, so
473
+ * loading the time zone tables fixes a running server without a restart.
474
+ */
475
+ const convertibleZones = /* @__PURE__ */ new WeakMap();
476
+ /**
388
477
  * MySQL adapter for {@link AtscriptDbTable}.
389
478
  *
390
479
  * Accepts any {@link TMysqlDriver} implementation — the actual MySQL driver
@@ -481,6 +570,13 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
481
570
  nativeDefaultFns() {
482
571
  return MysqlAdapter.NATIVE_DEFAULT_FNS;
483
572
  }
573
+ /**
574
+ * Every unit: `mysqlCalendarBucket` renders them all; named zones need the
575
+ * server's time zone tables (probed per zone in `aggregate()`).
576
+ */
577
+ calendarBucketUnits() {
578
+ return _atscript_db.ALL_BUCKET_UNITS;
579
+ }
484
580
  onBeforeFlatten(_type) {
485
581
  const meta = _type.metadata;
486
582
  const engine = meta.get("db.mysql.engine");
@@ -573,7 +669,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
573
669
  * converts epoch ms to a UTC datetime string for the wire protocol.
574
670
  */
575
671
  formatValue(field) {
576
- if (field.designType === "number" && field.defaultValue?.kind === "fn" && field.defaultValue.fn === "now") return {
672
+ if (isMysqlTimestampColumn(field)) return {
577
673
  toStorage: (value) => typeof value === "number" ? epochMsToUtcDatetime(value) : value,
578
674
  fromStorage: utcDatetimeToEpochMs
579
675
  };
@@ -636,26 +732,16 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
636
732
  };
637
733
  return this.withTransaction(async () => {
638
734
  const tableName = this.resolveTableName();
639
- const keys = Object.keys(data[0]);
640
- const colsClause = keys.map((k) => qi(k)).join(", ");
641
- const paramsPerRow = keys.length;
642
- const maxRowsPerBatch = paramsPerRow > 0 ? Math.floor(6e4 / paramsPerRow) : data.length;
735
+ const columns = (0, _atscript_db_sql_tools.insertManyColumns)(data);
736
+ const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
643
737
  const allIds = [];
644
- const rowPlaceholderClause = `(${keys.map(() => "?").join(", ")})`;
645
738
  for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
646
- const batchEnd = Math.min(offset + maxRowsPerBatch, data.length);
647
- const batchSize = batchEnd - offset;
648
- const params = [];
649
- for (let i = offset; i < batchEnd; i++) {
650
- const row = data[i];
651
- for (const k of keys) params.push(mysqlDialect.toValue(row[k]));
652
- }
653
- const valuesClause = Array(batchSize).fill(rowPlaceholderClause).join(", ");
654
- const sql = `INSERT INTO ${quoteTableName(tableName)} (${colsClause}) VALUES ${valuesClause}`;
739
+ const batch = data.slice(offset, offset + maxRowsPerBatch);
740
+ const { sql, params } = buildInsertMany(tableName, batch, columns);
655
741
  this._log(sql, params);
656
742
  const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
657
743
  const firstId = Number(result.insertId);
658
- for (let i = 0; i < batchSize; i++) allIds.push(this._resolveInsertedId(data[offset + i], firstId > 0 ? firstId + i : 0));
744
+ for (let i = 0; i < batch.length; i++) allIds.push(this._resolveInsertedId(batch[i], firstId > 0 ? firstId + i : 0));
659
745
  }
660
746
  return {
661
747
  insertedCount: allIds.length,
@@ -689,6 +775,7 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
689
775
  const search = (0, _atscript_db_agg.resolveAggregateSearch)(query.controls);
690
776
  const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
691
777
  const tableName = this.resolveTableName();
778
+ for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
692
779
  if (query.controls.$count) {
693
780
  const { sql, params } = buildAggregateCount(tableName, where, query.controls);
694
781
  this._log(sql, params);
@@ -698,6 +785,30 @@ var MysqlAdapter = class MysqlAdapter extends _atscript_db.BaseDbAdapter {
698
785
  this._log(sql, params);
699
786
  return this._exec().all(sql, params);
700
787
  }
788
+ /**
789
+ * Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
790
+ * any bucket SQL runs — MySQL never fails loudly here: it returns NULL when
791
+ * the time zone tables are not loaded (or lack the zone) and its input
792
+ * unchanged outside its supported range (before 8.0.28 that range ends in
793
+ * 2038). One probe ({@link TZ_PROBE_SQL}) detects both and raises
794
+ * `BUCKET_TZ_UNAVAILABLE` with the fix. `UTC` needs no probe — its
795
+ * expression skips `CONVERT_TZ`. Successes are cached per driver.
796
+ */
797
+ async _ensureBucketZone(tz) {
798
+ if (tz === "UTC" || convertibleZones.get(this.driver)?.has(tz)) return;
799
+ this._log(TZ_PROBE_SQL, [tz]);
800
+ const row = await this._exec().get(TZ_PROBE_SQL, [tz]);
801
+ let problem;
802
+ if (!row || Number(row.missing) !== 0) problem = "its time zone tables are not loaded or lack this zone — load them with mysql_tzinfo_to_sql";
803
+ else if (Number(row.shift) !== 60) problem = "its CONVERT_TZ does not convert instants after 2038 — MySQL 8.0.28 or later (64-bit) is required";
804
+ if (problem) throw (0, _atscript_db.bucketTimeZoneUnavailable)(`MySQL cannot convert to time zone "${tz}": ${problem}`);
805
+ let zones = convertibleZones.get(this.driver);
806
+ if (!zones) {
807
+ zones = /* @__PURE__ */ new Set();
808
+ convertibleZones.set(this.driver, zones);
809
+ }
810
+ zones.add(tz);
811
+ }
701
812
  async updateOne(filter, data, ops, expectedVersion) {
702
813
  const where = buildWhere(filter);
703
814
  const versionColumn = this._table.versionColumn;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
1
+ import { BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
2
2
  import { TMetadataMap } from "@atscript/typescript/utils";
3
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
4
  import { TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
@@ -132,6 +132,11 @@ declare class MysqlAdapter extends BaseDbAdapter {
132
132
  prepareId(id: unknown, _fieldType: unknown): unknown;
133
133
  supportsNativeValueDefaults(): boolean;
134
134
  nativeDefaultFns(): ReadonlySet<TDbDefaultFn>;
135
+ /**
136
+ * Every unit: `mysqlCalendarBucket` renders them all; named zones need the
137
+ * server's time zone tables (probed per zone in `aggregate()`).
138
+ */
139
+ calendarBucketUnits(): ReadonlySet<BucketUnit>;
135
140
  onBeforeFlatten(_type: unknown): void;
136
141
  onFieldScanned(field: string, _type: unknown, metadata: TMetadataMap<AtscriptMetadata>): void;
137
142
  getDesiredTableOptions(): TExistingTableOption[];
@@ -160,6 +165,16 @@ declare class MysqlAdapter extends BaseDbAdapter {
160
165
  findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
161
166
  count(query: DbQuery): Promise<number>;
162
167
  aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
168
+ /**
169
+ * Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
170
+ * any bucket SQL runs — MySQL never fails loudly here: it returns NULL when
171
+ * the time zone tables are not loaded (or lack the zone) and its input
172
+ * unchanged outside its supported range (before 8.0.28 that range ends in
173
+ * 2038). One probe ({@link TZ_PROBE_SQL}) detects both and raises
174
+ * `BUCKET_TZ_UNAVAILABLE` with the fix. `UTC` needs no probe — its
175
+ * expression skips `CONVERT_TZ`. Successes are cached per driver.
176
+ */
177
+ private _ensureBucketZone;
163
178
  updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number): Promise<TDbUpdateResult>;
164
179
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
165
180
  replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
- import { BaseDbAdapter, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
1
+ import { BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDefaultFn, TDbDeleteResult, TDbFieldMeta, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TExistingTableOption, TFieldOps, TPrimaryKeyChange, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TTableOptionDiff, TValueFormatterPair } from "@atscript/db";
2
2
  import { TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
3
- import { TMetadataMap } from "@atscript/typescript/utils";
4
3
  import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
4
+ import { TMetadataMap } from "@atscript/typescript/utils";
5
5
 
6
6
  //#region src/types.d.ts
7
7
  /**
@@ -132,6 +132,11 @@ declare class MysqlAdapter extends BaseDbAdapter {
132
132
  prepareId(id: unknown, _fieldType: unknown): unknown;
133
133
  supportsNativeValueDefaults(): boolean;
134
134
  nativeDefaultFns(): ReadonlySet<TDbDefaultFn>;
135
+ /**
136
+ * Every unit: `mysqlCalendarBucket` renders them all; named zones need the
137
+ * server's time zone tables (probed per zone in `aggregate()`).
138
+ */
139
+ calendarBucketUnits(): ReadonlySet<BucketUnit>;
135
140
  onBeforeFlatten(_type: unknown): void;
136
141
  onFieldScanned(field: string, _type: unknown, metadata: TMetadataMap<AtscriptMetadata>): void;
137
142
  getDesiredTableOptions(): TExistingTableOption[];
@@ -160,6 +165,16 @@ declare class MysqlAdapter extends BaseDbAdapter {
160
165
  findMany(query: DbQuery): Promise<Array<Record<string, unknown>>>;
161
166
  count(query: DbQuery): Promise<number>;
162
167
  aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
168
+ /**
169
+ * Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
170
+ * any bucket SQL runs — MySQL never fails loudly here: it returns NULL when
171
+ * the time zone tables are not loaded (or lack the zone) and its input
172
+ * unchanged outside its supported range (before 8.0.28 that range ends in
173
+ * 2038). One probe ({@link TZ_PROBE_SQL}) detects both and raises
174
+ * `BUCKET_TZ_UNAVAILABLE` with the fix. `UTC` needs no probe — its
175
+ * expression skips `CONVERT_TZ`. Successes are cached per driver.
176
+ */
177
+ private _ensureBucketZone;
163
178
  updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number): Promise<TDbUpdateResult>;
164
179
  updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
165
180
  replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
- import { BaseDbAdapter, DbError, DbSpace } from "@atscript/db";
1
+ import { ALL_BUCKET_UNITS, BaseDbAdapter, DbError, DbSpace, bucketTimeZoneUnavailable } from "@atscript/db";
2
2
  import { resolveAggregateSearch } from "@atscript/db/agg";
3
- import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, geoWindowFromControls, normalizeGeoPointValue, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, toSqlValue } from "@atscript/db-sql-tools";
3
+ import { buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildInsertMany, buildSelect, buildUpdate, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, fillReplacePayload, geoWindowFromControls, insertManyColumns, normalizeGeoPointValue, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, toSqlValue } from "@atscript/db-sql-tools";
4
+ import { BUCKET_MAX_INSTANT, BUCKET_MIN_INSTANT } from "@uniqu/core";
4
5
  //#region src/sql-builder.ts
5
6
  /** TEXT / BLOB families (`TINYTEXT` … `LONGBLOB`). */
6
7
  const TEXT_BLOB_RE = /^(TINY|MEDIUM|LONG)?(TEXT|BLOB)\b/i;
@@ -164,9 +165,74 @@ const mysqlDialect = {
164
165
  params: [...dist.params, circle.radius]
165
166
  };
166
167
  },
168
+ calendarBucket: mysqlCalendarBucket,
169
+ bucketAliasInHaving: true,
167
170
  createViewPrefix: "CREATE OR REPLACE VIEW"
168
171
  };
169
172
  /**
173
+ * Whether a field is stored as a native MySQL `TIMESTAMP`: a `number` with
174
+ * `@db.default.now`. The adapter writes such values as UTC
175
+ * `'YYYY-MM-DD HH:MM:SS'` strings and reads them back as epoch ms; every other
176
+ * numeric timestamp is a DOUBLE / BIGINT epoch-ms column.
177
+ */
178
+ function isMysqlTimestampColumn(fd) {
179
+ return fd.designType === "number" && fd.defaultValue?.kind === "fn" && fd.defaultValue.fn === "now";
180
+ }
181
+ /** `'1970-01-01 00:00:00'` as a DATETIME: the base for epoch arithmetic free of the session zone. */
182
+ const MYSQL_EPOCH = "CAST('1970-01-01 00:00:00' AS DATETIME)";
183
+ /**
184
+ * Calendar-bucket label: TEXT `'YYYY-MM-DD'` — the local date of the
185
+ * bucket's first day in `b.tz` — or NULL for a NULL source or one outside
186
+ * `[BUCKET_MIN_INSTANT, BUCKET_MAX_INSTANT)`.
187
+ *
188
+ * The source's UTC wall time `U` (a DATETIME) depends on storage:
189
+ * - DOUBLE / BIGINT epoch ms: `epoch + INTERVAL FLOOR(col / 1000) SECOND`
190
+ * (never `FROM_UNIXTIME`, which converts to the session zone);
191
+ * - native `TIMESTAMP` ({@link isMysqlTimestampColumn}): `CAST(col AS DATETIME)`
192
+ * — the session-zone rendering, which is exactly the UTC string the adapter
193
+ * wrote and parses back on read, so the label agrees with the value reads
194
+ * return whatever the server's session zone is (`UNIX_TIMESTAMP(col)` would
195
+ * agree only under a UTC session zone).
196
+ *
197
+ * The local date is `DATE(CONVERT_TZ(U, '+00:00', '<tz>'))`, or `DATE(U)` for
198
+ * `UTC` (no time zone tables needed). Truncation is calendar arithmetic on
199
+ * that date. `CONVERT_TZ` returns NULL when the zone is unknown to the
200
+ * server and its input unchanged outside its range — `MysqlAdapter` probes
201
+ * each zone before running the query (`BUCKET_TZ_UNAVAILABLE`).
202
+ *
203
+ * Parameter-free (the zone is an inlined, charset-checked literal), so the
204
+ * SELECT and GROUP BY renderings are identical (`ONLY_FULL_GROUP_BY`).
205
+ */
206
+ function mysqlCalendarBucket(quotedCol, b) {
207
+ let utc;
208
+ let inRange;
209
+ if (isMysqlTimestampColumn(b.fd)) {
210
+ utc = `CAST(${quotedCol} AS DATETIME)`;
211
+ inRange = `${utc} >= '1970-01-02 00:00:00'`;
212
+ } else {
213
+ utc = `(${MYSQL_EPOCH} + INTERVAL FLOOR(${quotedCol} / 1000) SECOND)`;
214
+ inRange = `${quotedCol} >= ${BUCKET_MIN_INSTANT} AND ${quotedCol} < ${BUCKET_MAX_INSTANT}`;
215
+ }
216
+ const local = b.tz === "UTC" ? `DATE(${utc})` : `DATE(CONVERT_TZ(${utc}, '+00:00', ${sqlTimeZoneLiteral(b.tz)}))`;
217
+ let first;
218
+ switch (b.unit) {
219
+ case "day":
220
+ first = local;
221
+ break;
222
+ case "week":
223
+ first = `DATE_SUB(${local}, INTERVAL ((WEEKDAY(${local}) + 1 - ${b.weekStartIso} + 7) % 7) DAY)`;
224
+ break;
225
+ case "month":
226
+ first = `DATE_SUB(${local}, INTERVAL DAYOFMONTH(${local}) - 1 DAY)`;
227
+ break;
228
+ case "quarter":
229
+ first = `(MAKEDATE(YEAR(${local}), 1) + INTERVAL (QUARTER(${local}) - 1) QUARTER)`;
230
+ break;
231
+ default: first = `MAKEDATE(YEAR(${local}), 1)`;
232
+ }
233
+ return `CASE WHEN ${inRange} THEN DATE_FORMAT(${first}, '%Y-%m-%d') END`;
234
+ }
235
+ /**
170
236
  * Spherical distance in meters from a POINT column to a query point.
171
237
  * Used as the `distExpr` of the shared geo search builder.
172
238
  */
@@ -209,6 +275,10 @@ function mysqlGeoValueToPoint(value) {
209
275
  function buildInsert$1(table, data) {
210
276
  return buildInsert(mysqlDialect, table, data);
211
277
  }
278
+ /** Multi-row INSERT over `columns` (a missing column → `DEFAULT`). */
279
+ function buildInsertMany$1(table, rows, columns) {
280
+ return buildInsertMany(mysqlDialect, table, rows, columns);
281
+ }
212
282
  /**
213
283
  * Builds a SELECT statement with optional sort, limit, offset, projection.
214
284
  */
@@ -290,7 +360,7 @@ function mysqlTypeFromField(field) {
290
360
  case "number":
291
361
  if (precision) return `DECIMAL(${precision.precision},${precision.scale})`;
292
362
  if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "increment") return unsigned ? "BIGINT UNSIGNED" : "BIGINT";
293
- if (field.defaultValue?.kind === "fn" && field.defaultValue.fn === "now") return "TIMESTAMP";
363
+ if (isMysqlTimestampColumn(field)) return "TIMESTAMP";
294
364
  if (tags?.has("int")) return intTypeFromTags(tags, unsigned);
295
365
  return "DOUBLE";
296
366
  case "integer": return intTypeFromTags(tags, unsigned);
@@ -384,6 +454,25 @@ function epochMsToUtcDatetime(ms) {
384
454
  return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
385
455
  }
386
456
  /**
457
+ * One statement answers both questions `CONVERT_TZ` never raises an error
458
+ * for: `missing` — the named zone (bound as `?`) converts to NULL, i.e. the
459
+ * time zone tables are not loaded or lack it; `shift` — a fixed `+01:00`
460
+ * conversion after 2038 is not applied (0 instead of 60 minutes), i.e. the
461
+ * server's `CONVERT_TZ` range ends in 2038 (before 8.0.28, or 32-bit).
462
+ *
463
+ * The range half deliberately uses a fixed offset, not the named zone: MySQL
464
+ * tz tables hold no transitions after 2037, so a zone such as `Europe/London`
465
+ * legitimately converts to +00:00 in June 2040 on a current server — an
466
+ * "unchanged" named-zone result is not evidence of a limited range.
467
+ */
468
+ 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";
469
+ /**
470
+ * Zones `CONVERT_TZ` is known to handle, per driver (adapters are per table
471
+ * and share a driver). Positives only: a failed zone is probed again, so
472
+ * loading the time zone tables fixes a running server without a restart.
473
+ */
474
+ const convertibleZones = /* @__PURE__ */ new WeakMap();
475
+ /**
387
476
  * MySQL adapter for {@link AtscriptDbTable}.
388
477
  *
389
478
  * Accepts any {@link TMysqlDriver} implementation — the actual MySQL driver
@@ -480,6 +569,13 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
480
569
  nativeDefaultFns() {
481
570
  return MysqlAdapter.NATIVE_DEFAULT_FNS;
482
571
  }
572
+ /**
573
+ * Every unit: `mysqlCalendarBucket` renders them all; named zones need the
574
+ * server's time zone tables (probed per zone in `aggregate()`).
575
+ */
576
+ calendarBucketUnits() {
577
+ return ALL_BUCKET_UNITS;
578
+ }
483
579
  onBeforeFlatten(_type) {
484
580
  const meta = _type.metadata;
485
581
  const engine = meta.get("db.mysql.engine");
@@ -572,7 +668,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
572
668
  * converts epoch ms to a UTC datetime string for the wire protocol.
573
669
  */
574
670
  formatValue(field) {
575
- if (field.designType === "number" && field.defaultValue?.kind === "fn" && field.defaultValue.fn === "now") return {
671
+ if (isMysqlTimestampColumn(field)) return {
576
672
  toStorage: (value) => typeof value === "number" ? epochMsToUtcDatetime(value) : value,
577
673
  fromStorage: utcDatetimeToEpochMs
578
674
  };
@@ -635,26 +731,16 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
635
731
  };
636
732
  return this.withTransaction(async () => {
637
733
  const tableName = this.resolveTableName();
638
- const keys = Object.keys(data[0]);
639
- const colsClause = keys.map((k) => qi(k)).join(", ");
640
- const paramsPerRow = keys.length;
641
- const maxRowsPerBatch = paramsPerRow > 0 ? Math.floor(6e4 / paramsPerRow) : data.length;
734
+ const columns = insertManyColumns(data);
735
+ const maxRowsPerBatch = columns.length > 0 ? Math.floor(6e4 / columns.length) : data.length;
642
736
  const allIds = [];
643
- const rowPlaceholderClause = `(${keys.map(() => "?").join(", ")})`;
644
737
  for (let offset = 0; offset < data.length; offset += maxRowsPerBatch) {
645
- const batchEnd = Math.min(offset + maxRowsPerBatch, data.length);
646
- const batchSize = batchEnd - offset;
647
- const params = [];
648
- for (let i = offset; i < batchEnd; i++) {
649
- const row = data[i];
650
- for (const k of keys) params.push(mysqlDialect.toValue(row[k]));
651
- }
652
- const valuesClause = Array(batchSize).fill(rowPlaceholderClause).join(", ");
653
- const sql = `INSERT INTO ${quoteTableName(tableName)} (${colsClause}) VALUES ${valuesClause}`;
738
+ const batch = data.slice(offset, offset + maxRowsPerBatch);
739
+ const { sql, params } = buildInsertMany$1(tableName, batch, columns);
654
740
  this._log(sql, params);
655
741
  const result = await this._wrapConstraintError(() => this._exec().run(sql, params));
656
742
  const firstId = Number(result.insertId);
657
- for (let i = 0; i < batchSize; i++) allIds.push(this._resolveInsertedId(data[offset + i], firstId > 0 ? firstId + i : 0));
743
+ for (let i = 0; i < batch.length; i++) allIds.push(this._resolveInsertedId(batch[i], firstId > 0 ? firstId + i : 0));
658
744
  }
659
745
  return {
660
746
  insertedCount: allIds.length,
@@ -688,6 +774,7 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
688
774
  const search = resolveAggregateSearch(query.controls);
689
775
  const where = search ? this._buildSearchWhere(search.text, query, search.indexName) : buildWhere(query.filter);
690
776
  const tableName = this.resolveTableName();
777
+ for (const bucket of query.controls.$select?.buckets ?? []) await this._ensureBucketZone(bucket.tz);
691
778
  if (query.controls.$count) {
692
779
  const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
693
780
  this._log(sql, params);
@@ -697,6 +784,30 @@ var MysqlAdapter = class MysqlAdapter extends BaseDbAdapter {
697
784
  this._log(sql, params);
698
785
  return this._exec().all(sql, params);
699
786
  }
787
+ /**
788
+ * Verifies that `CONVERT_TZ` can convert to a calendar bucket's zone before
789
+ * any bucket SQL runs — MySQL never fails loudly here: it returns NULL when
790
+ * the time zone tables are not loaded (or lack the zone) and its input
791
+ * unchanged outside its supported range (before 8.0.28 that range ends in
792
+ * 2038). One probe ({@link TZ_PROBE_SQL}) detects both and raises
793
+ * `BUCKET_TZ_UNAVAILABLE` with the fix. `UTC` needs no probe — its
794
+ * expression skips `CONVERT_TZ`. Successes are cached per driver.
795
+ */
796
+ async _ensureBucketZone(tz) {
797
+ if (tz === "UTC" || convertibleZones.get(this.driver)?.has(tz)) return;
798
+ this._log(TZ_PROBE_SQL, [tz]);
799
+ const row = await this._exec().get(TZ_PROBE_SQL, [tz]);
800
+ let problem;
801
+ if (!row || Number(row.missing) !== 0) problem = "its time zone tables are not loaded or lack this zone — load them with mysql_tzinfo_to_sql";
802
+ else if (Number(row.shift) !== 60) problem = "its CONVERT_TZ does not convert instants after 2038 — MySQL 8.0.28 or later (64-bit) is required";
803
+ if (problem) throw bucketTimeZoneUnavailable(`MySQL cannot convert to time zone "${tz}": ${problem}`);
804
+ let zones = convertibleZones.get(this.driver);
805
+ if (!zones) {
806
+ zones = /* @__PURE__ */ new Set();
807
+ convertibleZones.set(this.driver, zones);
808
+ }
809
+ zones.add(tz);
810
+ }
700
811
  async updateOne(filter, data, ops, expectedVersion) {
701
812
  const where = buildWhere(filter);
702
813
  const versionColumn = this._table.versionColumn;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mysql",
3
- "version": "0.1.131",
3
+ "version": "0.1.132",
4
4
  "description": "MySQL adapter for @atscript/db with mysql2 driver support.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -43,17 +43,17 @@
43
43
  "devDependencies": {
44
44
  "@atscript/core": "^0.1.92",
45
45
  "@atscript/typescript": "^0.1.92",
46
- "@uniqu/core": "^0.1.8",
46
+ "@uniqu/core": "^0.1.9",
47
47
  "mysql2": "^3.11.0",
48
48
  "unplugin-atscript": "^0.1.92"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@atscript/core": "^0.1.92",
52
52
  "@atscript/typescript": "^0.1.92",
53
- "@uniqu/core": "^0.1.8",
53
+ "@uniqu/core": "^0.1.9",
54
54
  "mysql2": ">=3.0.0",
55
- "@atscript/db": "^0.1.131",
56
- "@atscript/db-sql-tools": "^0.1.131"
55
+ "@atscript/db": "^0.1.132",
56
+ "@atscript/db-sql-tools": "^0.1.132"
57
57
  },
58
58
  "peerDependenciesMeta": {
59
59
  "mysql2": {