From cfd5dc1c72fac49d35bf27c26eba5295b184f313 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:53:33 +0000 Subject: [PATCH 1/3] fix(driver-sql): a MySQL NOW() datetime default carries its column's precision A declared Field.datetime with defaultValue 'NOW()' is built DATETIME(3) on MySQL, but nowColumnDefault's datetime arm fell through to a bare CURRENT_TIMESTAMP (precision 0). MySQL refuses a CURRENT_TIMESTAMP default whose precision differs from its column's, so the whole CREATE TABLE failed and sys_activity / sys_presence were never created. One source for the precision, MYSQL_DATETIME_PRECISION, now read by the declared column, the builtin audit columns (whose default routes through nowColumnDefault, as on SQLite), the NOW() default, the UPDATE stamp and the legacy TIMESTAMP widening. Postgres and SQLite emit unchanged SQL. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- packages/drivers/driver-sql/src/sql-driver.ts | 68 ++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index e65a0f08636..1ce6bf9b30c 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -382,6 +382,31 @@ const NUMERIC_SCALAR_TYPES = new Set([ */ const AUDIT_TIMESTAMP_COLUMNS = ['created_at', 'updated_at'] as const; +/** + * [#21241] The fractional-seconds precision of every MySQL `DATETIME` column + * this driver creates, and therefore of every server-clock expression that + * defaults or stamps one. `DATETIME(3)` keeps the canonical instant's + * milliseconds (#3942). + * + * ONE source, read by every site that spells it: the declared `Field.datetime` + * column ({@link SqlDriver.createColumn}), the builtin audit columns + * ({@link SqlDriver.createAuditTimestampColumn}), the `NOW()` column default + * ({@link SqlDriver.nowColumnDefault}), the UPDATE stamp + * ({@link SqlDriver.updatedAtStamp}) and the legacy `TIMESTAMP` widening + * ({@link SqlDriver.migrateMysqlDatetimeColumns}). + * + * A column and its default cannot disagree, and that is not a style point: + * MySQL refuses a `CURRENT_TIMESTAMP` default whose precision differs from its + * `DATETIME` column's (`ER_INVALID_DEFAULT`, "Invalid default value for …"), + * so the whole `CREATE TABLE` fails. The audit columns carried `now(3)` beside + * their `DATETIME(3)` as a second literal, and the declared-field default fell + * through to a bare `knex.fn.now()` beside the same `DATETIME(3)` column. + * Measured on MySQL 8.0.46: every table declaring a `defaultValue: 'NOW()'` + * datetime field (`sys_activity.timestamp`, `sys_presence.last_seen`) was + * never created, and its data door answered `500`. + */ +const MYSQL_DATETIME_PRECISION = 3; + /** * Read-side repair for the builtin audit timestamps on SQLite. * @@ -8348,7 +8373,7 @@ export class SqlDriver implements IDataDriver { */ protected updatedAtStamp(): string | Knex.Raw { if (this.isSqlite) return new Date().toISOString(); - return this.isMysql ? this.knex.fn.now(3) : this.knex.fn.now(); + return this.isMysql ? this.knex.fn.now(MYSQL_DATETIME_PRECISION) : this.knex.fn.now(); } /** @@ -13064,9 +13089,9 @@ export class SqlDriver implements IDataDriver { // `CURRENT_TIMESTAMP(3)` would start inserting NULL. const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name); const nullClause = col.nullable ? 'null' : 'not null'; - const defaultClause = isAudit ? ' default current_timestamp(3)' : ''; + const defaultClause = isAudit ? ` default current_timestamp(${MYSQL_DATETIME_PRECISION})` : ''; return { - sql: `alter table ?? modify column ?? datetime(3) ${nullClause}${defaultClause}`, + sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`, bindings: [table, col.name] as unknown[], }; }); @@ -17787,7 +17812,11 @@ export class SqlDriver implements IDataDriver { * The driver-native column DEFAULT for a `defaultValue: 'NOW()'` field. * * Postgres/MySQL use native `now()` — a real zone-aware TIMESTAMP that never - * had the ambiguity below. SQLite has no timestamp type and `knex.fn.now()` + * had the ambiguity below. On MySQL a `datetime` default carries the + * column's fractional-seconds precision ({@link MYSQL_DATETIME_PRECISION}), + * because MySQL refuses a mismatched one outright (#21241); it is also the + * expression the builtin audit columns default with, routed through here + * ({@link createAuditTimestampColumn}). SQLite has no timestamp type and `knex.fn.now()` * compiles to `CURRENT_TIMESTAMP`, which renders a timezone-NAIVE, * space-separated `'YYYY-MM-DD HH:MM:SS'` (no millis, no zone). `Date.parse` * reads such a zone-less string as LOCAL time, so a stored UTC wall-clock @@ -17833,6 +17862,14 @@ export class SqlDriver implements IDataDriver { if (this.isMysql) return this.knex.raw('(cast(utc_timestamp() as date))'); if (this.isPostgres) return this.knex.raw("(timezone('utc', now())::date)"); } + // `datetime` on MySQL (#21241): the column is `DATETIME(n)` (see + // `createColumn`), and MySQL REFUSES a `CURRENT_TIMESTAMP` default whose + // precision differs from its column's — the whole `CREATE TABLE` fails + // with "Invalid default value". The bare `knex.fn.now()` below is + // precision 0, so the default carries the column's precision from the + // one source both read. Postgres keeps the bare form: its `timestamptz` + // takes `CURRENT_TIMESTAMP` at microsecond precision, measured accepted. + if (type === 'datetime' && this.isMysql) return this.knex.fn.now(MYSQL_DATETIME_PRECISION); return this.knex.fn.now(); } switch (type) { @@ -17861,7 +17898,8 @@ export class SqlDriver implements IDataDriver { * bucketed like one, and must take the same physical type or they inherit the * `TIMESTAMP` problems on MySQL: no milliseconds, and a 2038 ceiling on the * column every list view sorts by (#3942). `CURRENT_TIMESTAMP` has to carry - * matching precision for a `DATETIME(3)` default, hence `now(3)`. + * matching precision for a `DATETIME(3)` default, hence `now(3)` — spelled + * {@link MYSQL_DATETIME_PRECISION}, the one source (#21241, below). * * ## SQLite takes the SAME canonical default a declared field gets (#11321) * @@ -17886,8 +17924,20 @@ export class SqlDriver implements IDataDriver { * does NOW() mean in DDL on this dialect". * * Postgres is deliberately untouched: `knex.fn.now()` there is a real - * zone-aware `TIMESTAMP` that never had the ambiguity. MySQL keeps `now(3)` - * (#11224) — a `DATETIME(3)` default must carry matching precision. + * zone-aware `TIMESTAMP` that never had the ambiguity. + * + * ## MySQL routes through the same source (#21241) + * + * A `DATETIME(3)` default must carry matching precision (#11224), and the + * declared-field default is the other half of that sentence too: this + * column carried its own `now(3)` while a declared `Field.datetime` NOW() + * column of the same `DATETIME(3)` type fell through to a bare + * `CURRENT_TIMESTAMP`, which MySQL refuses — so the table was never created. + * Both the column's precision and its default now read + * {@link MYSQL_DATETIME_PRECISION}, the default through + * {@link nowColumnDefault}, exactly as the SQLite branch does. The emitted + * DDL for this column is byte-identical (`datetime(3) default + * CURRENT_TIMESTAMP(3)`). * * ⚠️ Scope: a DDL default governs only NEWLY-created tables. A table already * on disk keeps its legacy `CURRENT_TIMESTAMP` default; `formatOutput`'s read @@ -17901,7 +17951,7 @@ export class SqlDriver implements IDataDriver { */ protected createAuditTimestampColumn(table: Knex.CreateTableBuilder, name: string): void { if (this.isMysql) { - table.datetime(name, { precision: 3 }).defaultTo(this.knex.fn.now(3)); + table.datetime(name, { precision: MYSQL_DATETIME_PRECISION }).defaultTo(this.nowColumnDefault('datetime')); return; } if (this.isSqlite) { @@ -19433,7 +19483,7 @@ export class SqlDriver implements IDataDriver { // driver writes, exactly as ServiceNow stores its MySQL timestamps // (#3942). Postgres deliberately keeps `table.timestamp` → `timestamptz`: // asking for precision 3 there would REDUCE it from microseconds. - col = this.isMysql ? table.datetime(name, { precision: 3 }) : table.timestamp(name); + col = this.isMysql ? table.datetime(name, { precision: MYSQL_DATETIME_PRECISION }) : table.timestamp(name); break; case 'time': // MySQL's bare `TIME` is zero-precision and ROUNDS a fractional literal From 04b23d27913c9030289e1049b4ddd2a02525eea3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:56:40 +0000 Subject: [PATCH 2/3] test(driver-sql): pin a NOW() datetime column to the builtin audit column on every dialect Section 1 compiles the DDL with no server: a declared NOW() datetime column is byte-identical to a builtin audit column on mysql2, pg and better-sqlite3, and on mysql2 the default names the column's own precision. Section 2 runs through declareDialectCell: the table syncs (create and add-column), an insert omitting the field answers the instant the column DEFAULT stored, and the server catalogue reports the declared column's type and default equal to created_at's. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- ...-21241-mysql-now-default-precision.test.ts | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts b/packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts new file mode 100644 index 00000000000..602572f1e0c --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21241] A declared `Field.datetime` defaulting to `'NOW()'` gets the SAME + * column a builtin audit timestamp gets, on every dialect, so MySQL accepts it. + * + * # The defect + * + * On MySQL a declared `Field.datetime` is built `DATETIME(3)`. Its `NOW()` + * default came from `nowColumnDefault('datetime')`, which fell through to a + * bare `knex.fn.now()`: `CURRENT_TIMESTAMP`, precision 0. MySQL refuses a + * `CURRENT_TIMESTAMP` default whose precision differs from its `DATETIME` + * column's, so the whole `CREATE TABLE` failed. Measured on MySQL 8.0.46 at + * `be5a83cf`, booting the CRM and the showcase: + * + * ``` + * create table `sys_activity` (… `timestamp` datetime(3) default CURRENT_TIMESTAMP …) + * - Invalid default value for 'timestamp' + * create table `sys_presence` (… `last_seen` datetime(3) default CURRENT_TIMESTAMP …) + * - Invalid default value for 'last_seen' + * ``` + * + * The builtin `created_at` / `updated_at` beside them carried `now(3)`, a + * second literal, and were accepted. The fix gives the precision ONE source + * (`MYSQL_DATETIME_PRECISION`) and routes the builtin default through + * `nowColumnDefault`, so a declared NOW() column and a builtin one are one + * definition. + * + * # What is asserted + * + * §1 runs on every runner, with no server: the DDL each dialect compiles for a + * declared NOW() datetime column is byte-identical to the builtin audit + * column's. Before the fix the `mysql2` row differed (`CURRENT_TIMESTAMP` + * against `CURRENT_TIMESTAMP(3)`); `pg` and `better-sqlite3` were already + * equal and are the control. + * + * §2 is the card's cell, one per dialect, through `declareDialectCell`: the + * table with a required NOW() datetime field SYNCS (create, and add-column on a + * table that already exists), an insert that omits the field answers the + * instant the column DEFAULT stored, and the server's own catalogue reports the + * declared column's type and default equal to `created_at`'s. SQLite and + * PostgreSQL passed before the fix and are the control; MySQL failed at sync. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { Knex } from 'knex'; +import { SqlDriver } from '../src/index.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'os21241_now_default'; + +/** The shape `sys_activity.timestamp` and `sys_presence.last_seen` declare. */ +const NOW_FIELD = { type: 'datetime', required: true, defaultValue: 'NOW()' }; + +const OBJECT = { + name: TABLE, + fields: { + title: { type: 'string' }, + stamped_at: NOW_FIELD, + }, +} as any; + +/** The same object before `stamped_at` was declared — the add-column door. */ +const OBJECT_WITHOUT_FIELD = { + name: TABLE, + fields: { title: { type: 'string' } }, +} as any; + +/** Compile DDL for one dialect without opening a connection. */ +class DdlProbe extends SqlDriver { + /** `create table` holding one column, built the way a declared field is. */ + declaredColumnSql(field: Record): string { + return this.knex.schema + .createTable('t', (table: Knex.CreateTableBuilder) => { + this.createColumn(table, 'c', field); + }) + .toString(); + } + + /** `create table` holding one column, built the way a builtin audit column is. */ + auditColumnSql(): string { + return this.knex.schema + .createTable('t', (table: Knex.CreateTableBuilder) => { + this.createAuditTimestampColumn(table, 'c'); + }) + .toString(); + } +} + +function probe(client: string): DdlProbe { + return new DdlProbe({ client, connection: { filename: ':memory:' }, useNullAsDefault: true } as any); +} + +// ── §1 One definition, compiled on every dialect ───────────────────────────── + +describe('#21241 §1 — a declared NOW() datetime column compiles to the builtin audit column', () => { + for (const client of ['mysql2', 'pg', 'better-sqlite3']) { + it(`${client}: the declared column and the audit column are byte-identical DDL`, async () => { + const driver = probe(client); + try { + // A plain declaration (no `storage.notNull`), so the declared column + // carries no `not null` the audit column lacks: what is left to differ + // is the type and the default. + const declared = driver.declaredColumnSql({ type: 'datetime', defaultValue: 'NOW()' }); + expect(declared).toBe(driver.auditColumnSql()); + // The comparison is not vacuous: the column really has a default. + expect(declared).toMatch(/ default /); + } finally { + await driver.disconnect(); + } + }); + } + + it('mysql2: a REQUIRED NOW() datetime column defaults at its own precision', async () => { + // The `sys_activity.timestamp` shape. Read off the compiled DDL rather + // than against a literal: whatever precision the column is built with, + // its CURRENT_TIMESTAMP default must name the same one (no `(n)` is + // precision 0, which is what MySQL refuses beside a `datetime(3)`). + const driver = probe('mysql2'); + try { + const sql = driver.declaredColumnSql(NOW_FIELD); + const m = /`c` datetime\((\d+)\)(?: not null)? default CURRENT_TIMESTAMP(?:\((\d+)\))?/.exec(sql); + expect(m, sql).not.toBeNull(); + expect(m![2] ?? '0', sql).toBe(m![1]); + } finally { + await driver.disconnect(); + } + }); +}); + +// ── §2 The live cells ──────────────────────────────────────────────────────── + +/** Whatever a dialect handed back for a timestamp, as epoch ms. */ +function asInstant(value: unknown): number { + if (value instanceof Date) return value.getTime(); + const text = String(value); + return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(text) ? text : `${text.replace(' ', 'T')}Z`); +} + +/** + * Slack for the server's clock against this process's. Wide on purpose: what + * it must still catch is a zone error (the CI servers run at +08:00, so a + * default resolved in the server's zone lands hours off), not clock jitter. + */ +const CLOCK_SLACK_MS = 60_000; + +/** The type and default the server's own catalogue reports for one column. */ +async function catalogueColumn(cell: DialectCell, knex: Knex, column: string): Promise> { + if (cell.id === 'mysql') { + const [rows]: any = await knex.raw( + 'select column_type as type, datetime_precision as fsp, column_default as dflt ' + + 'from information_schema.columns where table_schema = database() and table_name = ? and column_name = ?', + [TABLE, column], + ); + expect(rows, column).toHaveLength(1); + return { ...rows[0] }; + } + if (cell.id === 'pg') { + const res: any = await knex.raw( + 'select data_type as type, datetime_precision as fsp, column_default as dflt ' + + 'from information_schema.columns where table_schema = current_schema() and table_name = ? and column_name = ?', + [TABLE, column], + ); + expect(res.rows, column).toHaveLength(1); + return { ...res.rows[0] }; + } + const rows: any[] = await knex.raw(`pragma table_info(${TABLE})`); + const row = rows.find((r) => r.name === column); + expect(row, column).toBeDefined(); + return { type: row.type, dflt: row.dflt_value }; +} + +function measure(cell: DialectCell): void { + describe(`#21241 §2 — a required NOW() datetime field syncs and defaults (${cell.label})`, () => { + let driver: SqlDriver; + const knex = () => driver.getKnex(); + + beforeEach(async () => { + driver = new SqlDriver(cell.config()); + await knex().schema.dropTableIfExists(TABLE); + }); + + afterEach(async () => { + await knex().schema.dropTableIfExists(TABLE); + await driver.disconnect(); + }); + + it('creates the table, with the declared column typed and defaulted like created_at', async () => { + await driver.initObjects([OBJECT]); + + expect(await knex().schema.hasTable(TABLE)).toBe(true); + const declared = await catalogueColumn(cell, knex(), 'stamped_at'); + const builtin = await catalogueColumn(cell, knex(), 'created_at'); + expect(declared.dflt, 'the declared column has a default at all').not.toBeNull(); + expect(declared).toEqual(builtin); + if (cell.id === 'mysql') { + // The pairing MySQL enforces, read from the server rather than a literal. + expect(String(declared.dflt)).toBe(`CURRENT_TIMESTAMP(${declared.fsp})`); + } + }); + + it('an insert omitting the field answers the instant the column DEFAULT stored', async () => { + await driver.initObjects([OBJECT]); + + const before = Date.now(); + const answer = await driver.create(TABLE, { title: 'defaulted' }); + const after = Date.now(); + + expect(answer.stamped_at).not.toBeNull(); + expect(answer.stamped_at).not.toBeUndefined(); + const instant = asInstant(answer.stamped_at); + expect(instant).toBeGreaterThanOrEqual(before - CLOCK_SLACK_MS); + expect(instant).toBeLessThanOrEqual(after + CLOCK_SLACK_MS); + expect(answer).toEqual(await driver.findOne(TABLE, { where: { id: answer.id } })); + }); + + it('a raw insert that never names the column is filled by the DEFAULT alone', async () => { + // Past every app-side default path: nothing but the column DEFAULT can + // put a value in a column the statement never names. + await driver.initObjects([OBJECT]); + + const before = Date.now(); + await knex()(TABLE).insert({ id: 'raw-1', title: 'raw' }); + const after = Date.now(); + + const row = await driver.findOne(TABLE, { where: { id: 'raw-1' } }); + expect(row).not.toBeNull(); + const instant = asInstant(row!.stamped_at); + expect(instant).toBeGreaterThanOrEqual(before - CLOCK_SLACK_MS); + expect(instant).toBeLessThanOrEqual(after + CLOCK_SLACK_MS); + }); + + it('adds the NOW() column to a table that already exists, and it defaults there too', async () => { + await driver.initObjects([OBJECT_WITHOUT_FIELD]); + await driver.initObjects([OBJECT]); + + expect(await knex().schema.hasColumn(TABLE, 'stamped_at')).toBe(true); + const answer = await driver.create(TABLE, { title: 'after add-column' }); + expect(answer.stamped_at).not.toBeNull(); + expect(Number.isNaN(asInstant(answer.stamped_at))).toBe(false); + }); + }); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'NOW() datetime default precision (#21241)', measure); +} From e5cab5f20be6a62cdce16dc971f440b4e9c8b4e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:07:12 +0000 Subject: [PATCH 3/3] fix(driver-sql): the MySQL DATETIME widening keeps a declared NOW() default Widening a legacy TIMESTAMP column to DATETIME(3) with ALTER ... MODIFY restated the audit columns' default and dropped a declared NOW() field's, so an insert omitting the field stored NULL afterwards. The TIME twin already restated it. Both now take nowColumnDefault's expression. Adds the changeset and a MySQL-only pin for the widening. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../21241-mysql-now-default-precision.md | 15 ++++++++++ ...-21241-mysql-now-default-precision.test.ts | 28 +++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 11 +++++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/21241-mysql-now-default-precision.md diff --git a/.changeset/21241-mysql-now-default-precision.md b/.changeset/21241-mysql-now-default-precision.md new file mode 100644 index 00000000000..8e0e6a5c1a6 --- /dev/null +++ b/.changeset/21241-mysql-now-default-precision.md @@ -0,0 +1,15 @@ +--- +'@objectstack/driver-sql': patch +--- + +On MySQL, a table that declares a `Field.datetime` with `defaultValue: 'NOW()'` is now created (#21241). + +Clause-②: no + +On MySQL the driver builds a declared `Field.datetime` column as `DATETIME(3)`, but it gave the column's `NOW()` default a bare `CURRENT_TIMESTAMP`, which has precision 0. MySQL refuses a `CURRENT_TIMESTAMP` default whose precision differs from its column's (`Invalid default value for '…'`). The whole `CREATE TABLE` failed, and so did `ALTER TABLE … ADD` for a new field. The object's data endpoints then answered `500`. Two platform tables were affected: `sys_activity` and `sys_presence`. Record writes still succeeded, but none of them got an activity-timeline row. + +The default now carries the column's precision. It is `CURRENT_TIMESTAMP(3)`, the expression the builtin `created_at` / `updated_at` columns already used, and both now read one precision setting. PostgreSQL and SQLite emit the same DDL as before. + +One older case is fixed in the same place. A database created before datetime columns became `DATETIME(3)` holds them as `TIMESTAMP`. Schema sync widens those columns with `ALTER TABLE … MODIFY`, and that statement restated the default of `created_at` / `updated_at` but dropped the default of a declared `NOW()` field. After the widening, an insert that left the field out stored `NULL`. The widening now restates that default too, with the same expression. + +Nothing to change in a project. On the next boot, schema sync creates any table that failed before. No other migration is needed: on MySQL, no table could have been created with the refused default. A column that an earlier widening already left without a default does not get one back. diff --git a/packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts b/packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts index 602572f1e0c..e128e06eccb 100644 --- a/packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts @@ -40,6 +40,11 @@ * instant the column DEFAULT stored, and the server's own catalogue reports the * declared column's type and default equal to `created_at`'s. SQLite and * PostgreSQL passed before the fix and are the control; MySQL failed at sync. + * + * §3 is MySQL only: a legacy `TIMESTAMP` NOW() column, widened to `DATETIME(n)` + * at schema sync, keeps a default — the same one `created_at` keeps. Before, + * the widening restated the audit columns' default and dropped the declared + * one, so an insert omitting the field answered `null`. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -239,6 +244,29 @@ function measure(cell: DialectCell): void { expect(answer.stamped_at).not.toBeNull(); expect(Number.isNaN(asInstant(answer.stamped_at))).toBe(false); }); + + if (cell.id === 'mysql') { + it('§3 a legacy TIMESTAMP NOW() column keeps its default through the DATETIME widening', async () => { + // The pre-DATETIME(3) shape, when every datetime column was TIMESTAMP + // and a bare CURRENT_TIMESTAMP default was legal on it. Schema sync + // widens such a column with `ALTER … MODIFY`, which drops any default + // the statement does not restate. + await knex().raw( + `create table ${TABLE} (id varchar(255) not null primary key, ` + + 'created_at timestamp null default current_timestamp, ' + + 'updated_at timestamp null default current_timestamp, ' + + 'title varchar(255) null, stamped_at timestamp null default current_timestamp)', + ); + await driver.initObjects([OBJECT]); + + const declared = await catalogueColumn(cell, knex(), 'stamped_at'); + expect(String(declared.type), 'the widening ran').toMatch(/^datetime\(/); + expect(declared).toEqual(await catalogueColumn(cell, knex(), 'created_at')); + const answer = await driver.create(TABLE, { title: 'after widening' }); + expect(answer.stamped_at).not.toBeNull(); + expect(Number.isNaN(asInstant(answer.stamped_at))).toBe(false); + }); + } }); } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 1ce6bf9b30c..533a790b8a5 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -13087,9 +13087,18 @@ export class SqlDriver implements IDataDriver { // The default is re-stated because MySQL drops a column's DEFAULT when // MODIFY does not repeat it, and an audit column without // `CURRENT_TIMESTAMP(3)` would start inserting NULL. + // + // A declared `defaultValue: 'NOW()'` column is the same case (#21241), + // and the TIME twin ({@link migrateMysqlTimeColumns}) already restates + // it. Measured on MySQL 8.0.46 before this line: a legacy + // `timestamp null default current_timestamp` NOW() column came out of + // the widening as `datetime(3)` with NO default, and an insert omitting + // it answered `null`. Both take the expression a fresh column gets, + // from {@link nowColumnDefault}, so the widening cannot spell a third. const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name); + const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); const nullClause = col.nullable ? 'null' : 'not null'; - const defaultClause = isAudit ? ` default current_timestamp(${MYSQL_DATETIME_PRECISION})` : ''; + const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : ''; return { sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`, bindings: [table, col.name] as unknown[],