Skip to content

Commit 1cc66c9

Browse files
committed
refactor(driver-sql): regenerate the split with the durability-gate pin
Output of `node scripts/codemod-split-sql-driver.mjs --write` on sql-driver.ts at origin/main b391feb (input sha256 e73b1ce952e1), unedited: 54 method bodies and 97 top-level declarations move; sql-driver.ts 18333 -> 14042 lines. Output tree sha256 f4dc8078d9b7, the same on two runs. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2gLghEqeDKTbDTC7Zd
1 parent 8b8f32d commit 1cc66c9

2 files changed

Lines changed: 103 additions & 143 deletions

File tree

‎packages/drivers/driver-sql/src/sql-driver-rotation.ts‎

Lines changed: 2 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
/**
44
* [#29520 codemod] The "Data-lifecycle rotation (ADR-0057 P2)" section of
55
* `SqlDriver`: the bodies of `computeTenantField`,
6-
* `registerManagedObjectMetadata`, `migrateMysqlDatetimeColumns`,
7-
* `migrateMysqlTimeColumns` moved out of `sql-driver.ts` whole by
6+
* `registerManagedObjectMetadata` moved out of `sql-driver.ts` whole by
87
* `scripts/codemod-split-sql-driver.mjs`.
98
*
109
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
@@ -30,8 +29,7 @@ import type { AutonumberToken } from '@objectstack/spec/data';
3029
import { aggregandColumnClass } from '@objectstack/core';
3130
import { StorageNameMapping } from '@objectstack/spec/system';
3231
import {
33-
AUDIT_TIMESTAMP_COLUMNS, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, declaredValueShapes,
34-
isDeferredDdlLockWaitRefusal, isMultiValuedColumn, isNowDefaultValue,
32+
AUDIT_TIMESTAMP_COLUMNS, NUMERIC_SCALAR_TYPES, declaredValueShapes, isMultiValuedColumn,
3533
} from './sql-driver-top-level.js';
3634
import type { SqlDriver } from './sql-driver.js';
3735

@@ -49,20 +47,13 @@ export interface RotationHost {
4947
readonly datetimeFields: SqlDriver['datetimeFields'];
5048
readonly fractionalNumericFields: SqlDriver['fractionalNumericFields'];
5149
isJsonField: SqlDriver['isJsonField'];
52-
readonly isMysql: SqlDriver['isMysql'];
5350
readonly jsonFields: SqlDriver['jsonFields'];
54-
legacyMysqlTimeColumns: SqlDriver['legacyMysqlTimeColumns'];
55-
legacyMysqlTimestampColumns: SqlDriver['legacyMysqlTimestampColumns'];
56-
logDurabilityFailure: SqlDriver['logDurabilityFailure'];
57-
readonly logger: SqlDriver['logger'];
5851
readonly managedObjectFields: SqlDriver['managedObjectFields'];
5952
readonly managedObjectIndexes: SqlDriver['managedObjectIndexes'];
6053
readonly mediaFields: SqlDriver['mediaFields'];
61-
nowColumnDefault: SqlDriver['nowColumnDefault'];
6254
readonly numericFields: SqlDriver['numericFields'];
6355
readonly numericValueFields: SqlDriver['numericValueFields'];
6456
readonly physicalKeyIndexes: SqlDriver['physicalKeyIndexes'];
65-
runWideningAlters: SqlDriver['runWideningAlters'];
6657
readonly tenantFieldByTable: SqlDriver['tenantFieldByTable'];
6758
readonly timeFields: SqlDriver['timeFields'];
6859
readonly undeclaredAuditTimestampFields: SqlDriver['undeclaredAuditTimestampFields'];
@@ -201,105 +192,3 @@ export function registerManagedObjectMetadata(this: Pick<RotationHost, 'autoNumb
201192
}
202193
return { tableName, tenantField };
203194
}
204-
205-
/** The body of `SqlDriver#migrateMysqlDatetimeColumns`; the method's docblock there is its contract. */
206-
export async function migrateMysqlDatetimeColumns(this: Pick<RotationHost, 'isMysql' | 'legacyMysqlTimestampColumns' | 'logDurabilityFailure' | 'logger' | 'nowColumnDefault' | 'runWideningAlters'>, table: string, fields: Record<string, any>): Promise<void> {
207-
if (!this.isMysql) return;
208-
try {
209-
const legacy = await this.legacyMysqlTimestampColumns(table, fields);
210-
if (legacy.length === 0) return;
211-
212-
// #9354: built as a list, then run as one unit, so the whole widening of a
213-
// table shares the ONE pinned session the bounded lock wait needs.
214-
const statements = legacy.map((col) => {
215-
// The default is re-stated because MySQL drops a column's DEFAULT when
216-
// MODIFY does not repeat it, and an audit column without
217-
// `CURRENT_TIMESTAMP(3)` would start inserting NULL.
218-
//
219-
// A declared `defaultValue: 'NOW()'` column is the same case (#21241),
220-
// and the TIME twin ({@link migrateMysqlTimeColumns}) already restates
221-
// it. Measured on MySQL 8.0.46 before this line: a legacy
222-
// `timestamp null default current_timestamp` NOW() column came out of
223-
// the widening as `datetime(3)` with NO default, and an insert omitting
224-
// it answered `null`. Both take the expression a fresh column gets,
225-
// from {@link nowColumnDefault}, so the widening cannot spell a third.
226-
const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name);
227-
const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue);
228-
const nullClause = col.nullable ? 'null' : 'not null';
229-
const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : '';
230-
return {
231-
sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`,
232-
bindings: [table, col.name] as unknown[],
233-
};
234-
});
235-
await this.runWideningAlters(table, statements);
236-
this.logger.info?.(
237-
`[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` +
238-
`keeps no milliseconds and converts through the session timezone`,
239-
{ columns: legacy.map((c) => c.name) },
240-
);
241-
} catch (err) {
242-
// #9354: the ONE failure that must not be swallowed. Everything else on
243-
// this path keeps the policy the doc comment above describes; a metadata
244-
// lock wait that the flush bounded is the operator's answer to a command
245-
// they ran, and dropping it would report success for work not done.
246-
if (isDeferredDdlLockWaitRefusal(err)) throw err;
247-
this.logDurabilityFailure(
248-
`[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` +
249-
`continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` +
250-
`milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` +
251-
`else reports the table as un-widened. Fix: identify the metadata-lock holder with ` +
252-
'`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' +
253-
'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' +
254-
're-detected from `information_schema` and is idempotent, so re-running is safe.',
255-
{ error: err instanceof Error ? err.message : String(err) },
256-
);
257-
}
258-
}
259-
260-
/** The body of `SqlDriver#migrateMysqlTimeColumns`; the method's docblock there is its contract. */
261-
export async function migrateMysqlTimeColumns(this: Pick<RotationHost, 'isMysql' | 'legacyMysqlTimeColumns' | 'logDurabilityFailure' | 'logger' | 'runWideningAlters'>, table: string, fields: Record<string, any>): Promise<void> {
262-
if (!this.isMysql) return;
263-
try {
264-
const legacy = await this.legacyMysqlTimeColumns(table, fields);
265-
if (legacy.length === 0) return;
266-
267-
// #9354: the datetime twin's seam, for the same reason — this ALTER takes
268-
// the same exclusive metadata lock and blocks on the same holder.
269-
const statements = legacy.map((col) => {
270-
// MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'`
271-
// column gets the canonical UTC expression default (`nowColumnDefault`);
272-
// its legacy `current_timestamp()` default read the SESSION's zone, so
273-
// dropping-and-replacing it is a fix, not collateral.
274-
const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue);
275-
const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : '';
276-
return {
277-
sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`,
278-
bindings: [table, col.name] as unknown[],
279-
};
280-
});
281-
await this.runWideningAlters(table, statements);
282-
this.logger.info?.(
283-
`[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` +
284-
`canonical milliseconds to the nearest second, changing the stored wall clock`,
285-
{ columns: legacy.map((c) => c.name) },
286-
);
287-
} catch (err) {
288-
// #9354: the ONE failure that must not be swallowed. Everything else on
289-
// this path keeps the policy the doc comment above describes; a metadata
290-
// lock wait that the flush bounded is the operator's answer to a command
291-
// they ran, and dropping it would report success for work not done.
292-
if (isDeferredDdlLockWaitRefusal(err)) throw err;
293-
this.logDurabilityFailure(
294-
`[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` +
295-
`continued: the columns are still zero-precision TIME, so every fractional-second write to them ` +
296-
`keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` +
297-
`while reads return exactly what was stored and nothing else reports the table as un-widened. ` +
298-
'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' +
299-
'`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' +
300-
're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' +
301-
'and is idempotent, so re-running is safe.',
302-
{ error: err instanceof Error ? err.message : String(err) },
303-
);
304-
}
305-
}

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,10 @@ import {
227227
SCALAR_COMPARAND_OPERATORS, SEQUENCES_TABLE, SUPPORTED_FILTER_OPERATORS_SENTENCE, TEXT_OPERATORS,
228228
TEXT_PATTERN_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_DIAGNOSTIC, WITHHELD_FILTER_SUBTREE,
229229
canonicalTimeOfDay, canonicalUtcDatetime, declaresColumnNotNull, describeFilterOperand, fieldReferenceOf,
230-
isBindableComparand, isDeclaredFieldType, isEmptyFieldConstraint, isFilterNode, isMultiValuedColumn,
231-
isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral, nonBooleanFlagWithheldMessage,
232-
nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, resolveSqliteAbsentFileTarget,
233-
safeShapePreview, withheldFilterDiagnosticOf,
230+
isBindableComparand, isDeclaredFieldType, isDeferredDdlLockWaitRefusal, isEmptyFieldConstraint, isFilterNode,
231+
isMultiValuedColumn, isNowDefaultValue, isRenderableTextComparand, isUnresolvableColumnError, mysqlDatetimeLiteral,
232+
nonBooleanFlagWithheldMessage, nowAnchorOf, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType,
233+
resolveSqliteAbsentFileTarget, safeShapePreview, withheldFilterDiagnosticOf,
234234
} from './sql-driver-top-level.js';
235235
import type {
236236
AutoNumberReservation, ConflictTargetVerdict, FilterVerdict, IntrospectedColumn, IntrospectedForeignKey,
@@ -269,7 +269,7 @@ import { analyzeQuery } from './sql-driver-query-plan.js';
269269
// [#29520 codemod] Schema Sync (syncSchema / init): the methods of the same names below hand the bodies what they read (`SchemaSyncSectionHost`).
270270
import { reclaimSpace } from './sql-driver-schema-sync-section.js';
271271
// [#29520 codemod] Data-lifecycle rotation (ADR-0057 P2): the methods of the same names below hand the bodies what they read (`RotationHost`).
272-
import { computeTenantField, migrateMysqlDatetimeColumns, migrateMysqlTimeColumns, registerManagedObjectMetadata } from './sql-driver-rotation.js';
272+
import { computeTenantField, registerManagedObjectMetadata } from './sql-driver-rotation.js';
273273
// [#29520 codemod] Managed-schema drift & reconcile (#2186): the methods of the same names below hand the bodies what they read (`ManagedDriftHost`).
274274
import { buildRebuiltColumn, dropIndexIfExists, isProductionEnv, probeNullSafeUniqueDuplicates, syncTableIndexes } from './sql-driver-managed-drift.js';
275275
// [#29520 codemod] Internal helpers: the methods of the same names below hand the bodies what they read (`InternalHelpersHost`).
@@ -8068,22 +8068,61 @@ export class SqlDriver implements IDataDriver {
80688068
fn((sql, bindings) => trx.raw(sql, (bindings ?? []) as any)));
80698069
}
80708070

8071-
protected migrateMysqlDatetimeColumns(
8071+
protected async migrateMysqlDatetimeColumns(
80728072
table: string,
80738073
fields: Record<string, any>,
80748074
): Promise<void> {
8075-
// [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`).
8076-
const readIsMysql = () => this.isMysql;
8077-
const readLogger = () => this.logger;
8078-
const host: ThisParameterType<typeof migrateMysqlDatetimeColumns> = {
8079-
get isMysql() { return readIsMysql(); },
8080-
legacyMysqlTimestampColumns: (...a) => this.legacyMysqlTimestampColumns(...a),
8081-
logDurabilityFailure: (...a) => this.logDurabilityFailure(...a),
8082-
get logger() { return readLogger(); },
8083-
nowColumnDefault: (...a) => this.nowColumnDefault(...a),
8084-
runWideningAlters: (...a) => this.runWideningAlters(...a),
8085-
};
8086-
return migrateMysqlDatetimeColumns.call(host, table, fields);
8075+
if (!this.isMysql) return;
8076+
try {
8077+
const legacy = await this.legacyMysqlTimestampColumns(table, fields);
8078+
if (legacy.length === 0) return;
8079+
8080+
// #9354: built as a list, then run as one unit, so the whole widening of a
8081+
// table shares the ONE pinned session the bounded lock wait needs.
8082+
const statements = legacy.map((col) => {
8083+
// The default is re-stated because MySQL drops a column's DEFAULT when
8084+
// MODIFY does not repeat it, and an audit column without
8085+
// `CURRENT_TIMESTAMP(3)` would start inserting NULL.
8086+
//
8087+
// A declared `defaultValue: 'NOW()'` column is the same case (#21241),
8088+
// and the TIME twin ({@link migrateMysqlTimeColumns}) already restates
8089+
// it. Measured on MySQL 8.0.46 before this line: a legacy
8090+
// `timestamp null default current_timestamp` NOW() column came out of
8091+
// the widening as `datetime(3)` with NO default, and an insert omitting
8092+
// it answered `null`. Both take the expression a fresh column gets,
8093+
// from {@link nowColumnDefault}, so the widening cannot spell a third.
8094+
const isAudit = (AUDIT_TIMESTAMP_COLUMNS as readonly string[]).includes(col.name);
8095+
const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue);
8096+
const nullClause = col.nullable ? 'null' : 'not null';
8097+
const defaultClause = isAudit || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : '';
8098+
return {
8099+
sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`,
8100+
bindings: [table, col.name] as unknown[],
8101+
};
8102+
});
8103+
await this.runWideningAlters(table, statements);
8104+
this.logger.info?.(
8105+
`[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` +
8106+
`keeps no milliseconds and converts through the session timezone`,
8107+
{ columns: legacy.map((c) => c.name) },
8108+
);
8109+
} catch (err) {
8110+
// #9354: the ONE failure that must not be swallowed. Everything else on
8111+
// this path keeps the policy the doc comment above describes; a metadata
8112+
// lock wait that the flush bounded is the operator's answer to a command
8113+
// they ran, and dropping it would report success for work not done.
8114+
if (isDeferredDdlLockWaitRefusal(err)) throw err;
8115+
this.logDurabilityFailure(
8116+
`[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` +
8117+
`continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` +
8118+
`milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` +
8119+
`else reports the table as un-widened. Fix: identify the metadata-lock holder with ` +
8120+
'`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' +
8121+
'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' +
8122+
're-detected from `information_schema` and is idempotent, so re-running is safe.',
8123+
{ error: err instanceof Error ? err.message : String(err) },
8124+
);
8125+
}
80878126
}
80888127

80898128
/**
@@ -8132,21 +8171,53 @@ export class SqlDriver implements IDataDriver {
81328171
* as un-widened, so the level IS the signal. Same reasoning, same level, on
81338172
* {@link migrateMysqlDatetimeColumns}.
81348173
*/
8135-
protected migrateMysqlTimeColumns(
8174+
protected async migrateMysqlTimeColumns(
81368175
table: string,
81378176
fields: Record<string, any>,
81388177
): Promise<void> {
8139-
// [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`).
8140-
const readIsMysql = () => this.isMysql;
8141-
const readLogger = () => this.logger;
8142-
const host: ThisParameterType<typeof migrateMysqlTimeColumns> = {
8143-
get isMysql() { return readIsMysql(); },
8144-
legacyMysqlTimeColumns: (...a) => this.legacyMysqlTimeColumns(...a),
8145-
logDurabilityFailure: (...a) => this.logDurabilityFailure(...a),
8146-
get logger() { return readLogger(); },
8147-
runWideningAlters: (...a) => this.runWideningAlters(...a),
8148-
};
8149-
return migrateMysqlTimeColumns.call(host, table, fields);
8178+
if (!this.isMysql) return;
8179+
try {
8180+
const legacy = await this.legacyMysqlTimeColumns(table, fields);
8181+
if (legacy.length === 0) return;
8182+
8183+
// #9354: the datetime twin's seam, for the same reason — this ALTER takes
8184+
// the same exclusive metadata lock and blocks on the same holder.
8185+
const statements = legacy.map((col) => {
8186+
// MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'`
8187+
// column gets the canonical UTC expression default (`nowColumnDefault`);
8188+
// its legacy `current_timestamp()` default read the SESSION's zone, so
8189+
// dropping-and-replacing it is a fix, not collateral.
8190+
const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue);
8191+
const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : '';
8192+
return {
8193+
sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`,
8194+
bindings: [table, col.name] as unknown[],
8195+
};
8196+
});
8197+
await this.runWideningAlters(table, statements);
8198+
this.logger.info?.(
8199+
`[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` +
8200+
`canonical milliseconds to the nearest second, changing the stored wall clock`,
8201+
{ columns: legacy.map((c) => c.name) },
8202+
);
8203+
} catch (err) {
8204+
// #9354: the ONE failure that must not be swallowed. Everything else on
8205+
// this path keeps the policy the doc comment above describes; a metadata
8206+
// lock wait that the flush bounded is the operator's answer to a command
8207+
// they ran, and dropping it would report success for work not done.
8208+
if (isDeferredDdlLockWaitRefusal(err)) throw err;
8209+
this.logDurabilityFailure(
8210+
`[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` +
8211+
`continued: the columns are still zero-precision TIME, so every fractional-second write to them ` +
8212+
`keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` +
8213+
`while reads return exactly what was stored and nothing else reports the table as un-widened. ` +
8214+
'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' +
8215+
'`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' +
8216+
're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' +
8217+
'and is idempotent, so re-running is safe.',
8218+
{ error: err instanceof Error ? err.message : String(err) },
8219+
);
8220+
}
81508221
}
81518222

81528223
// ── Deferred schema DDL (#3917) ────────────────────────────────────────────

0 commit comments

Comments
 (0)