diff --git a/.changeset/21241-mysql-now-default-precision.md b/.changeset/21241-mysql-now-default-precision.md new file mode 100644 index 0000000000..8e0e6a5c1a --- /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 new file mode 100644 index 0000000000..e128e06ecc --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts @@ -0,0 +1,275 @@ +// 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. + * + * §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'; +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); + }); + + 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); + }); + } + }); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'NOW() datetime default precision (#21241)', measure); +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index e65a0f0863..533a790b8a 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(); } /** @@ -13062,11 +13087,20 @@ 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(3)' : ''; + const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : ''; 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 +17821,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 +17871,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 +17907,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 +17933,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 +17960,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 +19492,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