Skip to content

Commit 7923c8e

Browse files
fix(driver-sql): a MySQL NOW() datetime default carries its column's precision (#21252)
Fixes #21241 Clause-②: no ## What was wrong On MySQL a declared `Field.datetime` is built as `DATETIME(3)`. For its `defaultValue: 'NOW()'` default, `SqlDriver.nowColumnDefault('datetime')` fell through to a bare `knex.fn.now()`, which is `CURRENT_TIMESTAMP` at precision 0. MySQL 8.0 refuses a `CURRENT_TIMESTAMP` default whose precision differs from its column's, so the whole `CREATE TABLE` failed and so did `ALTER TABLE … ADD`. The builtin `created_at` / `updated_at` columns beside it carried their own literal `now(3)` and were accepted. Measured at the base `be5a83cf` against a throwaway MySQL 8.0.46 (server zone `+08:00`): | boot | schema sync | refused DDL | data door | |:--|:--|:--|:--| | `pnpm dev:crm -- --fresh --database mysql://…` | `synced 80, skipped 0, failed 1` | `sys_activity`: `` `timestamp` datetime(3) default CURRENT_TIMESTAMP `` → `Invalid default value for 'timestamp'` | `GET /api/v1/data/sys_activity?limit=1` → `500 DATABASE_ERROR` | | `pnpm dev:showcase -- --fresh --database mysql://…` | `synced 102, skipped 2, failed 2` | `sys_activity` as above, and `sys_presence`: `` `last_seen` datetime(3) default CURRENT_TIMESTAMP `` → `Invalid default value for 'last_seen'` | `GET /api/v1/data/sys_presence?limit=1` → `500`, `sys_activity` → `500` | ## The fix - **One source for the precision.** `MYSQL_DATETIME_PRECISION` is a module constant in `sql-driver.ts`. Five sites read it: the declared datetime column (`createColumn`), the builtin audit columns (`createAuditTimestampColumn`), the `NOW()` default (`nowColumnDefault`), the UPDATE stamp (`updatedAtStamp`) and the legacy `TIMESTAMP` widening (`migrateMysqlDatetimeColumns`). Before this PR, five literal `3`s lived at those sites. Now there is one, and no new literal was added. - **`nowColumnDefault('datetime')` on MySQL** returns `knex.fn.now(MYSQL_DATETIME_PRECISION)`, which renders `CURRENT_TIMESTAMP(3)`. - **The builtin audit column's MySQL default is routed through `nowColumnDefault('datetime')`**, the way the SQLite branch already is. The declared column and the builtin column now share one definition. The emitted DDL for the audit columns is byte-identical: `datetime(3) default CURRENT_TIMESTAMP(3)`. - PostgreSQL and SQLite emit unchanged SQL. On PostgreSQL the driver builds `timestamptz`, and `CURRENT_TIMESTAMP` on it was measured accepted on PostgreSQL 16.14. `timestamp(3) default CURRENT_TIMESTAMP` is also accepted there, rounding silently. So the mismatch does not exist on that dialect. ### Bounded in-place fix in the same statement: the `TIMESTAMP` widening dropped a declared `NOW()` default The widening's `ALTER … MODIFY` line now reads the constant, so this PR touches it. That same statement restated the default of `created_at` / `updated_at` and dropped the default of a declared `NOW()` column. The `TIME` twin (`migrateMysqlTimeColumns`) already restates it. Measured on MySQL 8.0.46 with the driver built at this branch before the change: a legacy `stamped_at timestamp null default current_timestamp` column came out of schema sync as `datetime(3)` with `COLUMN_DEFAULT` `null`, and `create` without the field answered `stamped_at = null`. The widening now restates `nowColumnDefault('datetime')` for a declared `NOW()` column as well. All four conditions for an in-place fix hold: - same family (a MySQL `NOW()` datetime default that is not honoured); - mechanical, with the shape fixed by the `TIME` twin; - same file and claim; - same gate family. The changed lines are `sql-driver.ts` `migrateMysqlDatetimeColumns`, and a MySQL-only pin, §3. ## Pins: `packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts` - **§1**, run on every runner with no server: for `mysql2`, `pg` and `better-sqlite3`, the compiled DDL of a declared `NOW()` datetime column is byte-identical to the builtin audit column's. A second case reads the `mysql2` DDL of the required `sys_activity.timestamp` shape and checks that its `CURRENT_TIMESTAMP(n)` names the column's own `datetime(n)`. This compares against the column, not against a literal. - **§2**, one cell per dialect through `declareDialectCell`: - the table syncs, both on create and on add-column for a table that already exists; - `create` without the field answers the instant the column `DEFAULT` stored. The check uses a window and confirms that the answer equals `findOne`; - a raw insert that never names the column is filled by the `DEFAULT` alone; - the server's own catalogue (`information_schema` / `pragma table_info`) reports the declared column's type and default equal to `created_at`'s. - **§3**, MySQL only: a legacy `TIMESTAMP` `NOW()` column keeps a default through the widening, equal to `created_at`'s. ## Verification (HEAD `d334fe314b`) Every reading below was re-run at `d334fe314b`: the branch plus one merge of `origin/main` `4727fcb22a`, which touches no `driver-sql` file and no lockfile. The readings match the earlier head `e5cab5f20b`, except the `driver-turso` count, which moved because #21226 landed on `main`. (Seat edit.) The live servers were local: MySQL 8.0.46 at `+08:00` and PostgreSQL 16.14 at `Asia/Shanghai`, with `TZ=America/New_York`. - Pins: `vitest run --reporter=verbose src/sql-driver-21241-mysql-now-default-precision.test.ts` with both URLs set → **17 passed (17)**. - **Reverse verification.** `sql-driver.ts` was written back to the base blob `e65a0f08` while the HEAD blob `533a790b` stayed committed. On-disk hash equal to the base blob, `grep -c MYSQL_DATETIME_PRECISION` → 0. Result: **7 failed | 10 passed (17)**. Every failure is MySQL: §1 `mysql2` ×2, §2 live mysql ×4 (`Invalid default value for 'stamped_at'` at create and at add-column), and §3. The SQLite and PostgreSQL cells stayed green (the control). The restore was checked: hash equal to the HEAD blob and `git diff HEAD` empty. - **Ablation of the widening half.** Through `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `533a790b` → `16057202`. Only §3 went red, `dflt: null` against `CURRENT_TIMESTAMP(3)`; the other 16 stayed green. Restored: blob equal to HEAD and `git diff HEAD` empty. - `@objectstack/driver-sql`, the whole suite against both live servers with `OS_EXPECT_LIVE_DIALECT_MATRIX=1`: **224 files passed, 5386 passed | 1 skipped**. The live-dialect reporter printed "all 3 dialects were exercised". - `@objectstack/driver-sql`, the SQLite tier (`pnpm --filter @objectstack/driver-sql test`, no URLs, at `04b23d2791` before the widening commit): 213 files passed | 11 skipped, 3570 passed | 200 skipped. - Typecheck of `@objectstack/driver-sql`, `@objectstack/driver-sqlite-wasm` and `@objectstack/driver-turso` (the inheritors): all three `Done`. `tsc --listFiles` includes the new test file once. - Tests of the inheritors: `driver-sqlite-wasm` 36 files / 675 passed; `driver-turso` 87 files / 2349 passed | 33 skipped. - `pnpm check:driver-conformance` gave the same reading before the first edit and after the last commit: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt`, with 0 in the DIALECT ledger. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 63 families. All 63 ran with exit 0. `--ran` reconciliation: `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN`, with every line recording its exit code. The derivation warned that the tree is one `main` commit behind (`3dc33b2d13`, file-disjoint from this diff, and it touches `check-route-envelope.mjs` / `engine-double-contract.pinned.json`). CI re-derives on the merge ref. - Lint, narrowed: - `eslint --no-inline-config --format json` over the two changed `.ts` files: 2 files, 0 errors, 0 warnings. - Population: both files are linted, not ignored. - Invariance: the config's `parserOptions` carry no `project` / `projectService`, so linting is not type-aware and this diff cannot move a verdict on an untouched file. - Repo-wide `pnpm lint` is left to CI. ### After the fix, same boot `pnpm dev:showcase -- --fresh --database mysql://…` at `e5cab5f20b`: - no `Schema sync FAILED`; - `GET /api/v1/data/sys_presence` → 200 and `sys_activity` → 200; - `information_schema` reports `sys_activity.timestamp` and `sys_presence.last_seen` as `datetime(3)` / `CURRENT_TIMESTAMP(3)`, equal to their `created_at`; - a `POST` / `PATCH` / `DELETE` on `showcase_account` left three `sys_activity` rows (`created`, `updated`, `deleted`). ## Raise-rule reading (triage) At the base on MySQL, with `sys_activity` absent, a missing table does not break a record write. `POST /api/v1/data/crm_account` → 201, `PATCH` → 200 (a read-back showed the new name), `DELETE` → 200 (a read-back answered 404). `sys_audit_log` holds 3 rows for the record. Each mutation lost its activity row: the server logged `Insert operation failed {object: sys_activity …}` at `warn` and `Audit write FAILED (ER_NO_SUCH_TABLE …)` at `error`. The API answers carried nothing about it. Applying the rule is the seat's decision. ## Acceptance notes - **Existing tables / migration (H5).** On MySQL no table could have been created with the refused default, so a fresh boot after this change creates the missing tables and no migration is owed. A column that an earlier release's `TIMESTAMP` widening already left without a default does not get one back from this change. The widening only touches columns that are still `timestamp`. This is a read-only inference beyond the measurement above. - **MariaDB not measured.** No MariaDB server here. `client: 'mariadb'` is not in the driver's MySQL family, but `mysql2` pointed at a MariaDB server is. Whether MariaDB accepted the bare default, and so holds tables with a precision-0 default, is NOT MEASURED. - **Operator text.** At the base, the `Audit write FAILED` line for the `sys_activity` insert names `sys_audit_log` as the row that "never landed" and as the table to check. It printed 4 times despite "reported ONCE". Observation only, nothing filed. - **`sys_packages`.** Its raw DDL (`created_at TEXT DEFAULT CURRENT_TIMESTAMP`) is still refused on MySQL in both boots. This was already recorded in PR #21239's acceptance notes; it is `domain:services`, and no door was measured. - **Not filed from this PR, handed to the seat in the report.** At the base on MySQL, `GET /api/v1/auth/jwks` and `GET /api/v1/auth/token` answered 500. An insert into `sys_jwks` is refused with `Incorrect datetime value … for column 'updated_at'`, and the server logs `JWT signing failed with alg "EdDSA"`. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d2bc644 commit 7923c8e

3 files changed

Lines changed: 358 additions & 9 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
On MySQL, a table that declares a `Field.datetime` with `defaultValue: 'NOW()'` is now created (#21241).
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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.
12+
13+
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.
14+
15+
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.
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21241] A declared `Field.datetime` defaulting to `'NOW()'` gets the SAME
5+
* column a builtin audit timestamp gets, on every dialect, so MySQL accepts it.
6+
*
7+
* # The defect
8+
*
9+
* On MySQL a declared `Field.datetime` is built `DATETIME(3)`. Its `NOW()`
10+
* default came from `nowColumnDefault('datetime')`, which fell through to a
11+
* bare `knex.fn.now()`: `CURRENT_TIMESTAMP`, precision 0. MySQL refuses a
12+
* `CURRENT_TIMESTAMP` default whose precision differs from its `DATETIME`
13+
* column's, so the whole `CREATE TABLE` failed. Measured on MySQL 8.0.46 at
14+
* `be5a83cf`, booting the CRM and the showcase:
15+
*
16+
* ```
17+
* create table `sys_activity` (… `timestamp` datetime(3) default CURRENT_TIMESTAMP …)
18+
* - Invalid default value for 'timestamp'
19+
* create table `sys_presence` (… `last_seen` datetime(3) default CURRENT_TIMESTAMP …)
20+
* - Invalid default value for 'last_seen'
21+
* ```
22+
*
23+
* The builtin `created_at` / `updated_at` beside them carried `now(3)`, a
24+
* second literal, and were accepted. The fix gives the precision ONE source
25+
* (`MYSQL_DATETIME_PRECISION`) and routes the builtin default through
26+
* `nowColumnDefault`, so a declared NOW() column and a builtin one are one
27+
* definition.
28+
*
29+
* # What is asserted
30+
*
31+
* §1 runs on every runner, with no server: the DDL each dialect compiles for a
32+
* declared NOW() datetime column is byte-identical to the builtin audit
33+
* column's. Before the fix the `mysql2` row differed (`CURRENT_TIMESTAMP`
34+
* against `CURRENT_TIMESTAMP(3)`); `pg` and `better-sqlite3` were already
35+
* equal and are the control.
36+
*
37+
* §2 is the card's cell, one per dialect, through `declareDialectCell`: the
38+
* table with a required NOW() datetime field SYNCS (create, and add-column on a
39+
* table that already exists), an insert that omits the field answers the
40+
* instant the column DEFAULT stored, and the server's own catalogue reports the
41+
* declared column's type and default equal to `created_at`'s. SQLite and
42+
* PostgreSQL passed before the fix and are the control; MySQL failed at sync.
43+
*
44+
* §3 is MySQL only: a legacy `TIMESTAMP` NOW() column, widened to `DATETIME(n)`
45+
* at schema sync, keeps a default — the same one `created_at` keeps. Before,
46+
* the widening restated the audit columns' default and dropped the declared
47+
* one, so an insert omitting the field answered `null`.
48+
*/
49+
50+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
51+
import type { Knex } from 'knex';
52+
import { SqlDriver } from '../src/index.js';
53+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
54+
55+
const TABLE = 'os21241_now_default';
56+
57+
/** The shape `sys_activity.timestamp` and `sys_presence.last_seen` declare. */
58+
const NOW_FIELD = { type: 'datetime', required: true, defaultValue: 'NOW()' };
59+
60+
const OBJECT = {
61+
name: TABLE,
62+
fields: {
63+
title: { type: 'string' },
64+
stamped_at: NOW_FIELD,
65+
},
66+
} as any;
67+
68+
/** The same object before `stamped_at` was declared — the add-column door. */
69+
const OBJECT_WITHOUT_FIELD = {
70+
name: TABLE,
71+
fields: { title: { type: 'string' } },
72+
} as any;
73+
74+
/** Compile DDL for one dialect without opening a connection. */
75+
class DdlProbe extends SqlDriver {
76+
/** `create table` holding one column, built the way a declared field is. */
77+
declaredColumnSql(field: Record<string, unknown>): string {
78+
return this.knex.schema
79+
.createTable('t', (table: Knex.CreateTableBuilder) => {
80+
this.createColumn(table, 'c', field);
81+
})
82+
.toString();
83+
}
84+
85+
/** `create table` holding one column, built the way a builtin audit column is. */
86+
auditColumnSql(): string {
87+
return this.knex.schema
88+
.createTable('t', (table: Knex.CreateTableBuilder) => {
89+
this.createAuditTimestampColumn(table, 'c');
90+
})
91+
.toString();
92+
}
93+
}
94+
95+
function probe(client: string): DdlProbe {
96+
return new DdlProbe({ client, connection: { filename: ':memory:' }, useNullAsDefault: true } as any);
97+
}
98+
99+
// ── §1 One definition, compiled on every dialect ─────────────────────────────
100+
101+
describe('#21241 §1 — a declared NOW() datetime column compiles to the builtin audit column', () => {
102+
for (const client of ['mysql2', 'pg', 'better-sqlite3']) {
103+
it(`${client}: the declared column and the audit column are byte-identical DDL`, async () => {
104+
const driver = probe(client);
105+
try {
106+
// A plain declaration (no `storage.notNull`), so the declared column
107+
// carries no `not null` the audit column lacks: what is left to differ
108+
// is the type and the default.
109+
const declared = driver.declaredColumnSql({ type: 'datetime', defaultValue: 'NOW()' });
110+
expect(declared).toBe(driver.auditColumnSql());
111+
// The comparison is not vacuous: the column really has a default.
112+
expect(declared).toMatch(/ default /);
113+
} finally {
114+
await driver.disconnect();
115+
}
116+
});
117+
}
118+
119+
it('mysql2: a REQUIRED NOW() datetime column defaults at its own precision', async () => {
120+
// The `sys_activity.timestamp` shape. Read off the compiled DDL rather
121+
// than against a literal: whatever precision the column is built with,
122+
// its CURRENT_TIMESTAMP default must name the same one (no `(n)` is
123+
// precision 0, which is what MySQL refuses beside a `datetime(3)`).
124+
const driver = probe('mysql2');
125+
try {
126+
const sql = driver.declaredColumnSql(NOW_FIELD);
127+
const m = /`c` datetime\((\d+)\)(?: not null)? default CURRENT_TIMESTAMP(?:\((\d+)\))?/.exec(sql);
128+
expect(m, sql).not.toBeNull();
129+
expect(m![2] ?? '0', sql).toBe(m![1]);
130+
} finally {
131+
await driver.disconnect();
132+
}
133+
});
134+
});
135+
136+
// ── §2 The live cells ────────────────────────────────────────────────────────
137+
138+
/** Whatever a dialect handed back for a timestamp, as epoch ms. */
139+
function asInstant(value: unknown): number {
140+
if (value instanceof Date) return value.getTime();
141+
const text = String(value);
142+
return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(text) ? text : `${text.replace(' ', 'T')}Z`);
143+
}
144+
145+
/**
146+
* Slack for the server's clock against this process's. Wide on purpose: what
147+
* it must still catch is a zone error (the CI servers run at +08:00, so a
148+
* default resolved in the server's zone lands hours off), not clock jitter.
149+
*/
150+
const CLOCK_SLACK_MS = 60_000;
151+
152+
/** The type and default the server's own catalogue reports for one column. */
153+
async function catalogueColumn(cell: DialectCell, knex: Knex, column: string): Promise<Record<string, unknown>> {
154+
if (cell.id === 'mysql') {
155+
const [rows]: any = await knex.raw(
156+
'select column_type as type, datetime_precision as fsp, column_default as dflt ' +
157+
'from information_schema.columns where table_schema = database() and table_name = ? and column_name = ?',
158+
[TABLE, column],
159+
);
160+
expect(rows, column).toHaveLength(1);
161+
return { ...rows[0] };
162+
}
163+
if (cell.id === 'pg') {
164+
const res: any = await knex.raw(
165+
'select data_type as type, datetime_precision as fsp, column_default as dflt ' +
166+
'from information_schema.columns where table_schema = current_schema() and table_name = ? and column_name = ?',
167+
[TABLE, column],
168+
);
169+
expect(res.rows, column).toHaveLength(1);
170+
return { ...res.rows[0] };
171+
}
172+
const rows: any[] = await knex.raw(`pragma table_info(${TABLE})`);
173+
const row = rows.find((r) => r.name === column);
174+
expect(row, column).toBeDefined();
175+
return { type: row.type, dflt: row.dflt_value };
176+
}
177+
178+
function measure(cell: DialectCell): void {
179+
describe(`#21241 §2 — a required NOW() datetime field syncs and defaults (${cell.label})`, () => {
180+
let driver: SqlDriver;
181+
const knex = () => driver.getKnex();
182+
183+
beforeEach(async () => {
184+
driver = new SqlDriver(cell.config());
185+
await knex().schema.dropTableIfExists(TABLE);
186+
});
187+
188+
afterEach(async () => {
189+
await knex().schema.dropTableIfExists(TABLE);
190+
await driver.disconnect();
191+
});
192+
193+
it('creates the table, with the declared column typed and defaulted like created_at', async () => {
194+
await driver.initObjects([OBJECT]);
195+
196+
expect(await knex().schema.hasTable(TABLE)).toBe(true);
197+
const declared = await catalogueColumn(cell, knex(), 'stamped_at');
198+
const builtin = await catalogueColumn(cell, knex(), 'created_at');
199+
expect(declared.dflt, 'the declared column has a default at all').not.toBeNull();
200+
expect(declared).toEqual(builtin);
201+
if (cell.id === 'mysql') {
202+
// The pairing MySQL enforces, read from the server rather than a literal.
203+
expect(String(declared.dflt)).toBe(`CURRENT_TIMESTAMP(${declared.fsp})`);
204+
}
205+
});
206+
207+
it('an insert omitting the field answers the instant the column DEFAULT stored', async () => {
208+
await driver.initObjects([OBJECT]);
209+
210+
const before = Date.now();
211+
const answer = await driver.create(TABLE, { title: 'defaulted' });
212+
const after = Date.now();
213+
214+
expect(answer.stamped_at).not.toBeNull();
215+
expect(answer.stamped_at).not.toBeUndefined();
216+
const instant = asInstant(answer.stamped_at);
217+
expect(instant).toBeGreaterThanOrEqual(before - CLOCK_SLACK_MS);
218+
expect(instant).toBeLessThanOrEqual(after + CLOCK_SLACK_MS);
219+
expect(answer).toEqual(await driver.findOne(TABLE, { where: { id: answer.id } }));
220+
});
221+
222+
it('a raw insert that never names the column is filled by the DEFAULT alone', async () => {
223+
// Past every app-side default path: nothing but the column DEFAULT can
224+
// put a value in a column the statement never names.
225+
await driver.initObjects([OBJECT]);
226+
227+
const before = Date.now();
228+
await knex()(TABLE).insert({ id: 'raw-1', title: 'raw' });
229+
const after = Date.now();
230+
231+
const row = await driver.findOne(TABLE, { where: { id: 'raw-1' } });
232+
expect(row).not.toBeNull();
233+
const instant = asInstant(row!.stamped_at);
234+
expect(instant).toBeGreaterThanOrEqual(before - CLOCK_SLACK_MS);
235+
expect(instant).toBeLessThanOrEqual(after + CLOCK_SLACK_MS);
236+
});
237+
238+
it('adds the NOW() column to a table that already exists, and it defaults there too', async () => {
239+
await driver.initObjects([OBJECT_WITHOUT_FIELD]);
240+
await driver.initObjects([OBJECT]);
241+
242+
expect(await knex().schema.hasColumn(TABLE, 'stamped_at')).toBe(true);
243+
const answer = await driver.create(TABLE, { title: 'after add-column' });
244+
expect(answer.stamped_at).not.toBeNull();
245+
expect(Number.isNaN(asInstant(answer.stamped_at))).toBe(false);
246+
});
247+
248+
if (cell.id === 'mysql') {
249+
it('§3 a legacy TIMESTAMP NOW() column keeps its default through the DATETIME widening', async () => {
250+
// The pre-DATETIME(3) shape, when every datetime column was TIMESTAMP
251+
// and a bare CURRENT_TIMESTAMP default was legal on it. Schema sync
252+
// widens such a column with `ALTER … MODIFY`, which drops any default
253+
// the statement does not restate.
254+
await knex().raw(
255+
`create table ${TABLE} (id varchar(255) not null primary key, ` +
256+
'created_at timestamp null default current_timestamp, ' +
257+
'updated_at timestamp null default current_timestamp, ' +
258+
'title varchar(255) null, stamped_at timestamp null default current_timestamp)',
259+
);
260+
await driver.initObjects([OBJECT]);
261+
262+
const declared = await catalogueColumn(cell, knex(), 'stamped_at');
263+
expect(String(declared.type), 'the widening ran').toMatch(/^datetime\(/);
264+
expect(declared).toEqual(await catalogueColumn(cell, knex(), 'created_at'));
265+
const answer = await driver.create(TABLE, { title: 'after widening' });
266+
expect(answer.stamped_at).not.toBeNull();
267+
expect(Number.isNaN(asInstant(answer.stamped_at))).toBe(false);
268+
});
269+
}
270+
});
271+
}
272+
273+
for (const cell of DIALECT_CELLS) {
274+
declareDialectCell(cell, 'NOW() datetime default precision (#21241)', measure);
275+
}

0 commit comments

Comments
 (0)