diff --git a/packages/drivers/driver-sql/src/sql-driver-schema-sync.ts b/packages/drivers/driver-sql/src/sql-driver-schema-sync.ts index d29e275e18d..8b345d7a5cf 100644 --- a/packages/drivers/driver-sql/src/sql-driver-schema-sync.ts +++ b/packages/drivers/driver-sql/src/sql-driver-schema-sync.ts @@ -119,6 +119,19 @@ * reference counts from `sql-driver.ts`, as before. `sql-driver.ts` no longer * reads `fieldHasColumn` as a value. * + * [#25521 cut 19] The declared column DEFAULT emission joined them the same + * way: `nowColumnDefault`, the driver-native DEFAULT for a `defaultValue: + * 'NOW()'` field on each dialect, and `applyDeclaredColumnDefault`, which + * decides what a field's `defaultValue` becomes in DDL and calls it. The SQLite + * rebuild above already reached both through the host; `createColumn` and + * `createAuditTimestampColumn`, which call them too, stay in the class, and so + * do both methods. `SchemaSyncHost` already listed every member these bodies + * read. The module-level declarations they read are imported from where they + * are declared: `MYSQL_DATETIME_PRECISION` and `isNowDefaultValue` (which now + * carry `export`) from `sql-driver.ts`, which still reads both, and + * `isRuntimeDefaultToken` from `@objectstack/spec/data`, which `sql-driver.ts` + * no longer reads. + * * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` * method of the same name as it stood, re-indented one level and otherwise * unedited: the same dialect statements, the same rebuild procedure, the same @@ -149,15 +162,16 @@ */ import { isUniqueViolationError } from '@objectstack/types'; +import type { Knex } from 'knex'; import { StorageNameMapping } from '@objectstack/spec/system'; import { carriesNullSafeTenantKeyPart, ceremonyOwnedDropFinding, describeMissingIndexColumns, diffManagedIndexes, diffManagedTable, diffUnbuildableIndexes, driftKey, expectedIndexes, fieldHasColumn, GLOBAL_TENANT, indexedKeyColumns, isCeremonyOwnedColumnDrop, isIndexDriftOp, legacyUniqueReplacements, normalizeDeclaredIndex, type DeclaredIndexInput, type DriftOp, type ManagedDriftEntry, type PendingSchemaWork, type PhysicalColumn } from './schema-drift.js'; import { isJsonColumnType, mediaColumnMoveDialect, mediaColumnMovePlan, type MediaColumnMovePlan, type MediaColumnMoveRefusal, type MediaColumnMoveScan } from './media-column-move.js'; import { StandardErrorCode } from '@objectstack/spec/api'; import { recoverUnencodedJsonText } from './unencoded-json-text.js'; import { aggregandColumnClass } from '@objectstack/core'; -import { FILE_REFERENCE_TYPES, NUMERIC_VALUE_TYPES, parseAutonumberFormat, resolveAutonumberFormat, type AutonumberToken } from '@objectstack/spec/data'; +import { FILE_REFERENCE_TYPES, isRuntimeDefaultToken, NUMERIC_VALUE_TYPES, parseAutonumberFormat, resolveAutonumberFormat, type AutonumberToken } from '@objectstack/spec/data'; import { formatAttribute, undeliveredStorageAttributes, type UndeliveredAttribute } from './builtin-column-collision.js'; -import { AUDIT_TIMESTAMP_COLUMNS, declaredValueShapes, type IntrospectedColumn, isMultiValuedColumn, LOCK_WAIT_REFUSAL, NUMERIC_SCALAR_TYPES, type SqlDriver, SQLITE_NON_TEMPORAL_TEXT_REFS, SQLITE_TIME_EXPR_REFS } from './sql-driver.js'; +import { AUDIT_TIMESTAMP_COLUMNS, declaredValueShapes, type IntrospectedColumn, isMultiValuedColumn, isNowDefaultValue, LOCK_WAIT_REFUSAL, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, type SqlDriver, SQLITE_NON_TEMPORAL_TEXT_REFS, SQLITE_TIME_EXPR_REFS } from './sql-driver.js'; /** * The `SqlDriver` members the functions below read — every one of them, and @@ -2064,3 +2078,71 @@ export async function previewTimeConvergence(this: Pick, type: string): Knex.Raw { + if (!this.isSqlite) { + // A `time` column deserves the same reasoning on the native dialects + // (#3994): `knex.fn.now()` compiles to CURRENT_TIMESTAMP, which a TIME + // column resolves in the SERVER's timezone on Postgres and the INSERTING + // session's timezone on MySQL — measured as three different wall clocks + // for one instant across the three dialects. Pin the default to the UTC + // time-of-day, matching what the driver's own writes and the SQLite + // branch below produce. (MySQL 8.0 additionally REJECTS a plain + // CURRENT_TIMESTAMP default on a TIME column — only the parenthesised + // expression form, MySQL 8.0.13+/MariaDB 10.2+, is legal there at all.) + if (type === 'time') { + if (this.isMysql) return this.knex.raw('(cast(utc_timestamp(3) as time(3)))'); + if (this.isPostgres) return this.knex.raw("(timezone('utc', now())::time(3))"); + } + // `date` has the same two problems one type over (#4022): a bare + // CURRENT_TIMESTAMP default resolves the calendar day in the SERVER's + // timezone on Postgres (measured: a UTC-12 server records YESTERDAY), + // and MySQL 8.0 rejects it on a DATE column outright (MariaDB is merely + // permissive; the driver's UTC-pinned session masked the semantic half + // there). Same fix as `time`: an expression default reading the UTC + // clock, which is also what `formatInput`'s NOW() safety-net and the + // SQLite branch below store. + if (type === 'date') { + 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) { + case 'date': return this.knex.raw("(strftime('%Y-%m-%d', 'now'))"); + // The CASE trims a zero-millisecond `.000` so a defaulted row is + // byte-canonical ({@link canonicalTimeOfDay}) — `%f` alone would store + // `'01:55:08.000'` once in a thousand inserts, and that row would then + // miss an equality filter against the canonical `'01:55:08'`. SQLite + // fixes `'now'` per statement, so the three calls cannot straddle a + // millisecond boundary. + case 'time': return this.knex.raw( + "(case when strftime('%H:%M:%f', 'now') like '%.000' " + + "then strftime('%H:%M:%S', 'now') else strftime('%H:%M:%f', 'now') end)", + ); + // datetime (and any non-temporal field that opts into NOW()): canonical instant. + default: return this.knex.raw("(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"); + } +} + +/** The body of `SqlDriver#applyDeclaredColumnDefault`; the method's docblock there is its contract. */ +export function applyDeclaredColumnDefault(this: Pick, col: Knex.ColumnBuilder, field: any, type: string): void { + const dv = field?.defaultValue; + if (dv === undefined || dv === null) return; + if (isNowDefaultValue(dv)) { + col.defaultTo(this.nowColumnDefault(type)); + return; + } + if (isRuntimeDefaultToken(dv)) return; + if (typeof dv === 'object') return; + col.defaultTo(dv as any); +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index d2ff2d1b982..1084637988b 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -55,7 +55,7 @@ import { // list of its own — which `defaultValue`s are instructions rather than literals, // so the engine and this driver can never disagree about what may become a // physical column DEFAULT. -import { isNowDefaultToken, isRuntimeDefaultToken } from '@objectstack/spec/data'; +import { isNowDefaultToken } from '@objectstack/spec/data'; // [#5702] The retired filter operators and the prescription each refusal // prints. Read from the spec rather than restated here for the reason the // #5701 table itself gives: five refusal sites that each write their own @@ -168,7 +168,9 @@ import { // federated registration `registerExternalObject`. // [#25521 cut 18] And the deferred-DDL previews (`previewDeferredSchemaWork`, `previewDatetimeConvergence`, // `previewTimeConvergence`): the read-only plan of what the deferred schema sync would do. -import { applyDriftOpInPlace, applyIndexDriftOp, applyMigrationEntries, applyNullSafeUniquePreflight, backfillCanonicalDatetimes, backfillCanonicalJsonEncoding, backfillCanonicalTimes, convergeJsonColumn, countNonTemporalTextRows, createNullSafeUniqueIndex, detectManagedDrift, detectTableDrift, detectTableIndexDrift, ensureRotation, legacyMysqlTimeColumns, legacyMysqlTimestampColumns, planMediaColumnMove, previewDatetimeConvergence, previewDeferredSchemaWork, previewTimeConvergence, rebuildSqliteTablePatched, reconcileAndWarnDrift, registerExternalObject, restoreBareIndexAfterFailedTighten, rotationColumnList, rotationShardKey, runWideningAlters, syncDeclaredIndexes, warnBuiltinColumnCollisions } from './sql-driver-schema-sync.js'; +// [#25521 cut 19] And the declared column DEFAULT emission (`nowColumnDefault`, `applyDeclaredColumnDefault`): +// what a field's `defaultValue` becomes in the DDL of a fresh or rebuilt column. +import { applyDeclaredColumnDefault, applyDriftOpInPlace, applyIndexDriftOp, applyMigrationEntries, applyNullSafeUniquePreflight, backfillCanonicalDatetimes, backfillCanonicalJsonEncoding, backfillCanonicalTimes, convergeJsonColumn, countNonTemporalTextRows, createNullSafeUniqueIndex, detectManagedDrift, detectTableDrift, detectTableIndexDrift, ensureRotation, legacyMysqlTimeColumns, legacyMysqlTimestampColumns, nowColumnDefault, planMediaColumnMove, previewDatetimeConvergence, previewDeferredSchemaWork, previewTimeConvergence, rebuildSqliteTablePatched, reconcileAndWarnDrift, registerExternalObject, restoreBareIndexAfterFailedTighten, rotationColumnList, rotationShardKey, runWideningAlters, syncDeclaredIndexes, warnBuiltinColumnCollisions } from './sql-driver-schema-sync.js'; // [#25521 cut 11] The autonumber sequence path (`getNextSequenceValue`, `fillAutoNumberFields`, // `issueAutonumbers`, `maxAutonumberCounter`, and the #5495 collision re-seed `collidingAutoNumberReservations` // and `resyncSequenceToDataMax`); the methods of the same names below hand each body the members it reads @@ -482,7 +484,7 @@ export const AUDIT_TIMESTAMP_COLUMNS = ['created_at', 'updated_at'] as const; * datetime field (`sys_activity.timestamp`, `sys_presence.last_seen`) was * never created, and its data door answered `500`. */ -const MYSQL_DATETIME_PRECISION = 3; +export const MYSQL_DATETIME_PRECISION = 3; /** * Read-side repair for the builtin audit timestamps on SQLite. @@ -572,7 +574,7 @@ function presentAuditTimestampOutput(value: unknown): unknown { * itself lives in `@objectstack/spec/data` so the engine's insert-time * resolution and this driver's DDL read one set (#4560). */ -const isNowDefaultValue = isNowDefaultToken; +export const isNowDefaultValue = isNowDefaultToken; /** * Read-side normalization for user-declared `Field.datetime` columns on SQLite. @@ -15474,57 +15476,18 @@ export class SqlDriver implements IDataDriver { * reads are uniform without a schema migration. */ protected nowColumnDefault(type: string): Knex.Raw { - if (!this.isSqlite) { - // A `time` column deserves the same reasoning on the native dialects - // (#3994): `knex.fn.now()` compiles to CURRENT_TIMESTAMP, which a TIME - // column resolves in the SERVER's timezone on Postgres and the INSERTING - // session's timezone on MySQL — measured as three different wall clocks - // for one instant across the three dialects. Pin the default to the UTC - // time-of-day, matching what the driver's own writes and the SQLite - // branch below produce. (MySQL 8.0 additionally REJECTS a plain - // CURRENT_TIMESTAMP default on a TIME column — only the parenthesised - // expression form, MySQL 8.0.13+/MariaDB 10.2+, is legal there at all.) - if (type === 'time') { - if (this.isMysql) return this.knex.raw('(cast(utc_timestamp(3) as time(3)))'); - if (this.isPostgres) return this.knex.raw("(timezone('utc', now())::time(3))"); - } - // `date` has the same two problems one type over (#4022): a bare - // CURRENT_TIMESTAMP default resolves the calendar day in the SERVER's - // timezone on Postgres (measured: a UTC-12 server records YESTERDAY), - // and MySQL 8.0 rejects it on a DATE column outright (MariaDB is merely - // permissive; the driver's UTC-pinned session masked the semantic half - // there). Same fix as `time`: an expression default reading the UTC - // clock, which is also what `formatInput`'s NOW() safety-net and the - // SQLite branch below store. - if (type === 'date') { - 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) { - case 'date': return this.knex.raw("(strftime('%Y-%m-%d', 'now'))"); - // The CASE trims a zero-millisecond `.000` so a defaulted row is - // byte-canonical ({@link canonicalTimeOfDay}) — `%f` alone would store - // `'01:55:08.000'` once in a thousand inserts, and that row would then - // miss an equality filter against the canonical `'01:55:08'`. SQLite - // fixes `'now'` per statement, so the three calls cannot straddle a - // millisecond boundary. - case 'time': return this.knex.raw( - "(case when strftime('%H:%M:%f', 'now') like '%.000' " + - "then strftime('%H:%M:%S', 'now') else strftime('%H:%M:%f', 'now') end)", - ); - // datetime (and any non-temporal field that opts into NOW()): canonical instant. - default: return this.knex.raw("(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"); - } + // [#25521 cut 19] The body moved to `sql-driver-schema-sync.ts` (see `SchemaSyncHost`). + const readIsMysql = () => this.isMysql; + const readIsPostgres = () => this.isPostgres; + const readIsSqlite = () => this.isSqlite; + const readKnex = () => this.knex; + const host: ThisParameterType = { + get isMysql() { return readIsMysql(); }, + get isPostgres() { return readIsPostgres(); }, + get isSqlite() { return readIsSqlite(); }, + get knex() { return readKnex(); }, + }; + return nowColumnDefault.call(host, type); } /** @@ -17380,15 +17343,11 @@ export class SqlDriver implements IDataDriver { * `sql-driver-option-default-ddl.test.ts`. */ protected applyDeclaredColumnDefault(col: Knex.ColumnBuilder, field: any, type: string): void { - const dv = field?.defaultValue; - if (dv === undefined || dv === null) return; - if (isNowDefaultValue(dv)) { - col.defaultTo(this.nowColumnDefault(type)); - return; - } - if (isRuntimeDefaultToken(dv)) return; - if (typeof dv === 'object') return; - col.defaultTo(dv as any); + // [#25521 cut 19] The body moved to `sql-driver-schema-sync.ts` (see `SchemaSyncHost`). + const host: ThisParameterType = { + nowColumnDefault: (...a) => this.nowColumnDefault(...a), + }; + return applyDeclaredColumnDefault.call(host, col, field, type); } // ── Database helpers ────────────────────────────────────────────────────────