From fe7fb7ec5ba8aab6b6715ac56bf1e9c36d622f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:08:36 +0000 Subject: [PATCH] refactor(driver-sql): move the declared column DEFAULT emission out of sql-driver.ts into sql-driver-schema-sync.ts Cut 19 of the sql-driver.ts split. The bodies of nowColumnDefault and applyDeclaredColumnDefault move unedited into the existing seam module, through SchemaSyncHost, which already listed every member they read. The class keeps both methods as delegating stubs. MYSQL_DATETIME_PRECISION and isNowDefaultValue gain export in place; isRuntimeDefaultToken is now read only by the module. sql-driver.ts: 18,447 -> 18,406 lines. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m67V6oBcEqg8yLDKL --- .../driver-sql/src/sql-driver-schema-sync.ts | 86 +++++++++++++++++- packages/drivers/driver-sql/src/sql-driver.ts | 87 +++++-------------- 2 files changed, 107 insertions(+), 66 deletions(-) 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 ────────────────────────────────────────────────────────