From d3ca391c9eca6f31cfbded4d38466ff71db28b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:53:57 +0000 Subject: [PATCH] refactor(driver-sql): the durability gate and the compile-refusal enumeration read the sql-driver facade, so the codemod moves four more members (#30147 phase 2b) check-durability-degradation-log-level.mjs follows a catch's log helper across the facade (FACADE_LOG_HOP): `this.x()` in a sql-driver-*.ts facet module resolves to SqlDriver#x through the module's host interface, and a `Facet.x.call(Host.of(this), ...)` stub is read as the facet body. sql-driver-compile-refusal-seam.test.ts reads every sql-driver-*.ts module and names a facet static as the SqlDriver method it is. The codemod drops the logDurabilityFailure pin, the runWideningAlters needle and the CLASS_PATH_READER_NEEDLES rows; regenerated from 21510bba, logDurabilityFailure, migrateMysqlDatetimeColumns, migrateMysqlTimeColumns and resolveWithheldFilterRefusal move (175/175 bodies identical, public .d.ts byte-identical). Registry 73 -> 69 rows. Claude-Session: https://claude.ai/code/session_01WGufzj4B2cbnRLVdgtLcDZ Co-authored-by: Claude --- .../driver-sql/src/sql-driver-class-head.ts | 21 +- .../sql-driver-compile-refusal-seam.test.ts | 33 +- .../src/sql-driver-filter-helpers.ts | 34 +- .../driver-sql/src/sql-driver-rotation.ts | 117 +++++- packages/drivers/driver-sql/src/sql-driver.ts | 141 ++------ ...check-durability-degradation-log-level.mjs | 340 +++++++++++++++++- scripts/codemod-split-sql-driver.mjs | 30 +- scripts/facade-delegation-registry.json | 24 -- 8 files changed, 542 insertions(+), 198 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-class-head.ts b/packages/drivers/driver-sql/src/sql-driver-class-head.ts index fbed04a74e0..53eee7c7c41 100644 --- a/packages/drivers/driver-sql/src/sql-driver-class-head.ts +++ b/packages/drivers/driver-sql/src/sql-driver-class-head.ts @@ -2,13 +2,13 @@ /** * [#29520 codemod] The "Class head: dialect probes, logger, configuration" - * section of `SqlDriver`: the bodies of `logger`, `emissionClients`, - * `clientSpelling`, `insertReturnsStoredRows`, `aliasIdentifierSql`, - * `dateGranularityCapabilities`, `dateBucketSql`, `knexConfigFor`, - * `withConnectBound`, `withUtcSession`, `withMysqlCalendarDayAsText`, - * `withPostgresCalendarDayAsText`, `installQueryTiming`, `assertSchemaMutable`, - * `assertDialectEmits` moved out of `sql-driver.ts` whole by - * `scripts/codemod-split-sql-driver.mjs`. + * section of `SqlDriver`: the bodies of `logger`, `logDurabilityFailure`, + * `emissionClients`, `clientSpelling`, `insertReturnsStoredRows`, + * `aliasIdentifierSql`, `dateGranularityCapabilities`, `dateBucketSql`, + * `knexConfigFor`, `withConnectBound`, `withUtcSession`, + * `withMysqlCalendarDayAsText`, `withPostgresCalendarDayAsText`, + * `installQueryTiming`, `assertSchemaMutable`, `assertDialectEmits` moved out + * of `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. * * A MOVE, not a redesign. {@link ClassHeadFacet} is a stateless facet of `SqlDriver` * (ruling 6624345766): each static method of it is the body of the `SqlDriver` @@ -65,6 +65,7 @@ export interface ClassHeadHost { readonly isPostgres: SqlDriverClass['isPostgres']; readonly isSqlite: SqlDriverClass['isSqlite']; readonly knex: SqlDriverClass['knex']; + readonly logger: SqlDriverClass['logger']; openedEmptyInMemory: SqlDriverClass['openedEmptyInMemory']; readonly schemaMode: SqlDriverClass['schemaMode']; } @@ -79,6 +80,12 @@ export class ClassHeadFacet { }; } + /** The body of `SqlDriver#logDurabilityFailure`; the method's docblock there is its contract. */ + static logDurabilityFailure(this: Pick, msg: string, meta?: any): void { + if (this.logger.error) this.logger.error(msg, meta); + else this.logger.warn(msg, meta); + } + /** The body of `SqlDriver#emissionClients`; the method's docblock there is its contract. */ static emissionClients(): string[] { return [ diff --git a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts index 67f0f1e2f1e..4fe85d09b5e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts @@ -32,6 +32,8 @@ * fault module `sql-driver-read-exit-fault.ts` (#25521 cut 15) and the * predicate-emitter module `sql-driver-predicate-emitters.ts` (#25521 cut 16) * and the generated top-level leaf `sql-driver-top-level.ts` (#29520 codemod), + * and, since #30147, every other `sql-driver-*.ts` module beside it (a + * facet member is named as the `SqlDriver` method whose body it is), * and lists every function that builds an `INVALID_FILTER` error. Only three may call * `unsupportedFilterError` directly (the seam itself, the `'author'` * re-issue, and the column refusal raised from a dialect error AFTER the @@ -48,7 +50,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import ts from 'typescript'; import { SqlDriver } from './index.js'; import { markFilterSubtreeProvenance, type FilterCondition } from '@objectstack/spec/data'; @@ -275,12 +277,12 @@ const DOORS: readonly Door[] = [ // ── Half 1: the enumeration ─────────────────────────────────────────────────── /** - * Every function in `sql-driver.ts`, `sql-driver-cross-field-comparison.ts`, - * `sql-driver-read-exit-fault.ts` and `sql-driver-predicate-emitters.ts` that builds an `INVALID_FILTER` error, by the route it takes. Read with the TypeScript parser, not a regex, so a call + * Every function in `sql-driver.ts` and every `sql-driver-*.ts` module beside it that builds an `INVALID_FILTER` error, by the route it takes. Read with the TypeScript parser, not a regex, so a call * split across lines, an inline `throw`, or a builder written as a method or an * arrow cannot slip past the count. */ function enumerateRefusalBuilders(): { + files: string[]; directCallers: Set; seamCallers: Set; codeWriters: Set; @@ -293,13 +295,14 @@ function enumerateRefusalBuilders(): { // `$empty` refusals; the scan reads it too. // [#29520 codemod] The top-level declarations ahead of the class moved to a // generated leaf, with the refusal builders among them; the scan reads it too. - const sources = [ - ['sql-driver.ts', readFileSync(new URL('./sql-driver.ts', import.meta.url), 'utf8')], - ['sql-driver-cross-field-comparison.ts', readFileSync(new URL('./sql-driver-cross-field-comparison.ts', import.meta.url), 'utf8')], - ['sql-driver-read-exit-fault.ts', readFileSync(new URL('./sql-driver-read-exit-fault.ts', import.meta.url), 'utf8')], - ['sql-driver-predicate-emitters.ts', readFileSync(new URL('./sql-driver-predicate-emitters.ts', import.meta.url), 'utf8')], - ['sql-driver-top-level.ts', readFileSync(new URL('./sql-driver-top-level.ts', import.meta.url), 'utf8')], - ] as const; + // [#30147] The codemod moves a class method's body into a facet class + // (`export class Facet`) in any section module, so the scan reads every + // `sql-driver-*.ts` module, not a list a move can outrun, and names a facet's + // static `x` as `SqlDriver.x`: it is that method's body. + const files = readdirSync(new URL('.', import.meta.url)) + .filter((f) => /^sql-driver(-[a-z0-9-]+)?\.ts$/.test(f) && !f.endsWith('.test.ts')) + .sort(); + const sources = files.map((f) => [f, readFileSync(new URL(`./${f}`, import.meta.url), 'utf8')] as const); const directCallers = new Set(); const seamCallers = new Set(); const codeWriters = new Set(); @@ -310,7 +313,7 @@ function enumerateRefusalBuilders(): { if (ts.isMethodDeclaration(at) && ts.isIdentifier(at.name)) { const owner = at.parent; const cls = ts.isClassDeclaration(owner) && owner.name ? owner.name.text : '?'; - return `${cls}.${at.name.text}`; + return `${/^[A-Z][A-Za-z0-9]*Facet$/.test(cls) ? 'SqlDriver' : cls}.${at.name.text}`; } if ((ts.isArrowFunction(at) || ts.isFunctionExpression(at)) && ts.isVariableDeclaration(at.parent)) { const decl = at.parent; @@ -357,7 +360,7 @@ function enumerateRefusalBuilders(): { ts.forEachChild(node, visit); }; for (const [name, source] of sources) visit(ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true)); - return { directCallers, seamCallers, codeWriters }; + return { files, directCallers, seamCallers, codeWriters }; } describe('[#20039] the filter-compile refusal class is closed — enumeration', () => { @@ -376,6 +379,12 @@ describe('[#20039] the filter-compile refusal class is closed — enumeration', expect(found.seamCallers, builder).toContain(builder); } expect(found.directCallers).toContain('unresolvableFilterColumnError'); + // [#30147] The modules the builders were found in before the scan read + // every module, and one facet module, are among those it reads. + for (const f of ['sql-driver.ts', 'sql-driver-cross-field-comparison.ts', 'sql-driver-read-exit-fault.ts', + 'sql-driver-predicate-emitters.ts', 'sql-driver-top-level.ts', 'sql-driver-filter-helpers.ts']) { + expect(found.files, `the scan lost ${f}`).toContain(f); + } }); it('only three functions build the envelope WITHOUT the seam, none of them on the compile path', () => { diff --git a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts index aaab7a9506d..054a87f4c6b 100644 --- a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts @@ -11,9 +11,10 @@ * `temporalFilterColumnSql`, `isJsonColumn`, `jsonColumnFieldClass`, * `isNonTextColumn`, `applyTextOperatorOverNonTextColumn`, * `declaredValueShape`, `assertOperatorAppliesToColumn`, `applyFilters`, - * `logWithheldFilterDiagnostic`, `withWithheldFilterLog`, `compileFilters`, - * `applyContainsLike`, `declaredFieldsFor`, `applyFalseConstant` moved out of - * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * `resolveWithheldFilterRefusal`, `logWithheldFilterDiagnostic`, + * `withWithheldFilterLog`, `compileFilters`, `applyContainsLike`, + * `declaredFieldsFor`, `applyFalseConstant` moved out of `sql-driver.ts` whole + * by `scripts/codemod-split-sql-driver.mjs`. * * A MOVE, not a redesign. {@link FilterHelpersFacet} is a stateless facet of `SqlDriver` * (ruling 6624345766): each static method of it is the body of the `SqlDriver` @@ -38,16 +39,18 @@ * evaluation cycle. `index.ts` re-exports nothing from this module. */ +import { resolveFilterSubtreeProvenance } from '@objectstack/spec/data'; import type { DriverOptions, ValueShapeFieldDef } from '@objectstack/spec/data'; import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, findMisplacedReadableIds, presentAsNumber, refuseReadableIdsOnce, temporalStorageForm } from '@objectstack/core'; import type { JsonColumnFieldClass } from '@objectstack/core'; import { StorageNameMapping } from '@objectstack/spec/system'; import type { Knex } from 'knex'; import { - AUDIT_TIMESTAMP_COLUMNS, SQLITE_TIME_EXPR_REFS, WITHHELD_FILTER_LOGGED, assertCompilableComparand, - assertDefinedComparands, canonicalTimeOfDay, canonicalUtcDatetime, emptyFieldConstraintError, - filterArrayReachedDriverError, isEmptyFieldConstraint, jsonColumnOperatorError, mysqlDatetimeLiteral, - normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, refusalSubtree, withheldFilterDiagnosticOf, + AUDIT_TIMESTAMP_COLUMNS, SQLITE_TIME_EXPR_REFS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_LOGGED, + WITHHELD_FILTER_SUBTREE, assertCompilableComparand, assertDefinedComparands, canonicalTimeOfDay, + canonicalUtcDatetime, emptyFieldConstraintError, filterArrayReachedDriverError, isEmptyFieldConstraint, + jsonColumnOperatorError, mysqlDatetimeLiteral, normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, + refusalSubtree, unsupportedFilterError, withheldFilterDiagnosticOf, } from './sql-driver-top-level.js'; import type { ReadPresentationKind } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; @@ -79,6 +82,7 @@ export interface FilterHelpersHost { readonly isSqlite: SqlDriver['isSqlite']; jsonColumnFieldClass: SqlDriver['jsonColumnFieldClass']; readonly jsonFields: SqlDriver['jsonFields']; + logWithheldFilterDiagnostic: SqlDriver['logWithheldFilterDiagnostic']; readonly logger: SqlDriver['logger']; readonly managedObjectFields: SqlDriver['managedObjectFields']; readonly mediaFields: SqlDriver['mediaFields']; @@ -430,6 +434,22 @@ export class FilterHelpersFacet { } } + /** The body of `SqlDriver#resolveWithheldFilterRefusal`; the method's docblock there is its contract. */ + static resolveWithheldFilterRefusal(this: Pick, err: unknown, rootFilter: unknown): unknown { + const diagnostic = withheldFilterDiagnosticOf(err); + if (diagnostic === null) return err; + const carrier = err as Record; + const subtree = carrier[WITHHELD_FILTER_SUBTREE]; + const provenance = + subtree === undefined ? null : resolveFilterSubtreeProvenance(rootFilter, subtree); + if (provenance === 'author') { + const authorText = carrier[WITHHELD_FILTER_AUTHOR_TEXT]; + return unsupportedFilterError(typeof authorText === 'string' ? authorText : diagnostic); + } + this.logWithheldFilterDiagnostic(err); + return err; + } + /** The body of `SqlDriver#logWithheldFilterDiagnostic`; the method's docblock there is its contract. */ static logWithheldFilterDiagnostic(this: Pick, err: unknown): void { const diagnostic = withheldFilterDiagnosticOf(err); diff --git a/packages/drivers/driver-sql/src/sql-driver-rotation.ts b/packages/drivers/driver-sql/src/sql-driver-rotation.ts index 7592959d85b..a4d89425d9e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-rotation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-rotation.ts @@ -5,8 +5,9 @@ * `SqlDriver`: the bodies of `rotationShardsOf`, `rotationWriteTarget`, * `rotateShards`, `aliasShardBookkeeping`, `computeTenantField`, * `computeAndRecordTenantField`, `registerManagedObjectMetadata`, - * `registerObjectMetadata`, `withPinnedSession` moved out of `sql-driver.ts` - * whole by `scripts/codemod-split-sql-driver.mjs`. + * `registerObjectMetadata`, `withPinnedSession`, `migrateMysqlDatetimeColumns`, + * `migrateMysqlTimeColumns` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. * * A MOVE, not a redesign. {@link RotationFacet} is a stateless facet of `SqlDriver` * (ruling 6624345766): each static method of it is the body of the `SqlDriver` @@ -36,7 +37,8 @@ import type { AutonumberToken } from '@objectstack/spec/data'; import { aggregandColumnClass } from '@objectstack/core'; import { StorageNameMapping, SystemObjectName } from '@objectstack/spec/system'; import { - AUDIT_TIMESTAMP_COLUMNS, NUMERIC_SCALAR_TYPES, declaredValueShapes, isMultiValuedColumn, + AUDIT_TIMESTAMP_COLUMNS, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, declaredValueShapes, + isDeferredDdlLockWaitRefusal, isMultiValuedColumn, isNowDefaultValue, } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; @@ -59,17 +61,24 @@ export interface RotationHost { ensureRotation: SqlDriver['ensureRotation']; readonly fractionalNumericFields: SqlDriver['fractionalNumericFields']; isJsonField: SqlDriver['isJsonField']; + readonly isMysql: SqlDriver['isMysql']; readonly jsonFields: SqlDriver['jsonFields']; readonly knex: SqlDriver['knex']; + legacyMysqlTimeColumns: SqlDriver['legacyMysqlTimeColumns']; + legacyMysqlTimestampColumns: SqlDriver['legacyMysqlTimestampColumns']; + logDurabilityFailure: SqlDriver['logDurabilityFailure']; + readonly logger: SqlDriver['logger']; readonly managedObjectFields: SqlDriver['managedObjectFields']; readonly managedObjectIndexes: SqlDriver['managedObjectIndexes']; readonly mediaFields: SqlDriver['mediaFields']; + nowColumnDefault: SqlDriver['nowColumnDefault']; readonly numericFields: SqlDriver['numericFields']; readonly numericValueFields: SqlDriver['numericValueFields']; organizationObjectRegistered: SqlDriver['organizationObjectRegistered']; readonly physicalKeyIndexes: SqlDriver['physicalKeyIndexes']; registerManagedObjectMetadata: SqlDriver['registerManagedObjectMetadata']; readonly rotationStateByTable: SqlDriver['rotationStateByTable']; + runWideningAlters: SqlDriver['runWideningAlters']; readonly supportsRotation: SqlDriver['supportsRotation']; readonly tablesWithTimestamps: SqlDriver['tablesWithTimestamps']; readonly tenantFieldByTable: SqlDriver['tenantFieldByTable']; @@ -278,4 +287,106 @@ export class RotationFacet { return await this.knex.transaction(async (trx) => fn((sql, bindings) => trx.raw(sql, (bindings ?? []) as any))); } + + /** The body of `SqlDriver#migrateMysqlDatetimeColumns`; the method's docblock there is its contract. */ + static async migrateMysqlDatetimeColumns(this: Pick, table: string, fields: Record): Promise { + if (!this.isMysql) return; + try { + const legacy = await this.legacyMysqlTimestampColumns(table, fields); + if (legacy.length === 0) return; + + // #9354: built as a list, then run as one unit, so the whole widening of a + // table shares the ONE pinned session the bounded lock wait needs. + const statements = legacy.map((col) => { + // 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 || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : ''; + return { + sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`, + bindings: [table, col.name] as unknown[], + }; + }); + await this.runWideningAlters(table, statements); + this.logger.info?.( + `[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` + + `keeps no milliseconds and converts through the session timezone`, + { columns: legacy.map((c) => c.name) }, + ); + } catch (err) { + // #9354: the ONE failure that must not be swallowed. Everything else on + // this path keeps the policy the doc comment above describes; a metadata + // lock wait that the flush bounded is the operator's answer to a command + // they ran, and dropping it would report success for work not done. + if (isDeferredDdlLockWaitRefusal(err)) throw err; + this.logDurabilityFailure( + `[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` + + `continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` + + `milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` + + `else reports the table as un-widened. Fix: identify the metadata-lock holder with ` + + '`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' + + 'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' + + 're-detected from `information_schema` and is idempotent, so re-running is safe.', + { error: err instanceof Error ? err.message : String(err) }, + ); + } + } + + /** The body of `SqlDriver#migrateMysqlTimeColumns`; the method's docblock there is its contract. */ + static async migrateMysqlTimeColumns(this: Pick, table: string, fields: Record): Promise { + if (!this.isMysql) return; + try { + const legacy = await this.legacyMysqlTimeColumns(table, fields); + if (legacy.length === 0) return; + + // #9354: the datetime twin's seam, for the same reason — this ALTER takes + // the same exclusive metadata lock and blocks on the same holder. + const statements = legacy.map((col) => { + // MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'` + // column gets the canonical UTC expression default (`nowColumnDefault`); + // its legacy `current_timestamp()` default read the SESSION's zone, so + // dropping-and-replacing it is a fix, not collateral. + const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); + const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : ''; + return { + sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`, + bindings: [table, col.name] as unknown[], + }; + }); + await this.runWideningAlters(table, statements); + this.logger.info?.( + `[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` + + `canonical milliseconds to the nearest second, changing the stored wall clock`, + { columns: legacy.map((c) => c.name) }, + ); + } catch (err) { + // #9354: the ONE failure that must not be swallowed. Everything else on + // this path keeps the policy the doc comment above describes; a metadata + // lock wait that the flush bounded is the operator's answer to a command + // they ran, and dropping it would report success for work not done. + if (isDeferredDdlLockWaitRefusal(err)) throw err; + this.logDurabilityFailure( + `[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` + + `continued: the columns are still zero-precision TIME, so every fractional-second write to them ` + + `keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` + + `while reads return exactly what was stored and nothing else reports the table as un-widened. ` + + 'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' + + '`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' + + 're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' + + 'and is idempotent, so re-running is safe.', + { error: err instanceof Error ? err.message : String(err) }, + ); + } + } } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 25ddd703881..a70e5588747 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -228,13 +228,11 @@ import { findMisplacedReadableIds, refuseReadableIdsOnce } from '@objectstack/co import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/spec/data'; // [#29520 codemod] The top-level declarations that moved whole to `sql-driver-top-level.ts`, as this file still reads them. import { - AUDIT_TIMESTAMP_COLUMNS, AUTONUMBER_COLLISION_RETRIES, CROSS_FIELD_COMPARISON_OPERATORS, MYSQL_DATETIME_PRECISION, - SEQUENCES_TABLE, TEXT_OPERATORS, WITHHELD_FILTER_AUTHOR_TEXT, WITHHELD_FILTER_SUBTREE, assertCompilableComparand, - bareFieldReferenceError, betweenArityError, declaresColumnNotNull, fieldReferenceOf, isDeclaredFieldType, - isDeferredDdlLockWaitRefusal, isFilterNode, isMultiValuedColumn, isNowDefaultValue, nowAnchorOf, - nowAnchorPositionError, reduceFilterKey, reduceFilterNode, refusalSubtree, refuseRejectedReferenceAlias, - refuseUndeclaredFieldType, retiredFilterOperatorError, unsupportedFilterError, unsupportedFilterOperatorError, - withheldFilterDiagnosticOf, + AUTONUMBER_COLLISION_RETRIES, CROSS_FIELD_COMPARISON_OPERATORS, MYSQL_DATETIME_PRECISION, SEQUENCES_TABLE, + TEXT_OPERATORS, assertCompilableComparand, bareFieldReferenceError, betweenArityError, declaresColumnNotNull, + fieldReferenceOf, isDeclaredFieldType, isFilterNode, isMultiValuedColumn, nowAnchorOf, nowAnchorPositionError, + reduceFilterKey, reduceFilterNode, refusalSubtree, refuseRejectedReferenceAlias, refuseUndeclaredFieldType, + retiredFilterOperatorError, unsupportedFilterOperatorError, } from './sql-driver-top-level.js'; import type { AutoNumberReservation, ConflictTargetVerdict, IntrospectedColumn, IntrospectedForeignKey, IntrospectedSchema, @@ -878,8 +876,8 @@ export class SqlDriver implements IDataDriver { * Reason ② is why this method exists; it is not what this conversion bought. */ protected logDurabilityFailure(msg: string, meta?: any): void { - if (this.logger.error) this.logger.error(msg, meta); - else this.logger.warn(msg, meta); + // [#29520 codemod] The body moved to `sql-driver-class-head.ts` (see `ClassHeadHost`). + return ClassHeadFacet.logDurabilityFailure.call(classHeadHost.of(this), msg, meta); } /** @@ -5146,61 +5144,12 @@ export class SqlDriver implements IDataDriver { return (RotationFacet.withPinnedSession).call(rotationHost.of(this), fn); } - protected async migrateMysqlDatetimeColumns( + protected migrateMysqlDatetimeColumns( table: string, fields: Record, ): Promise { - if (!this.isMysql) return; - try { - const legacy = await this.legacyMysqlTimestampColumns(table, fields); - if (legacy.length === 0) return; - - // #9354: built as a list, then run as one unit, so the whole widening of a - // table shares the ONE pinned session the bounded lock wait needs. - const statements = legacy.map((col) => { - // 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 || isNowDefault ? ` default ${this.nowColumnDefault('datetime').toString()}` : ''; - return { - sql: `alter table ?? modify column ?? datetime(${MYSQL_DATETIME_PRECISION}) ${nullClause}${defaultClause}`, - bindings: [table, col.name] as unknown[], - }; - }); - await this.runWideningAlters(table, statements); - this.logger.info?.( - `[sql-driver] widened MySQL TIMESTAMP → DATETIME(3) on ${table}: TIMESTAMP ends at 2038-01-19, ` + - `keeps no milliseconds and converts through the session timezone`, - { columns: legacy.map((c) => c.name) }, - ); - } catch (err) { - // #9354: the ONE failure that must not be swallowed. Everything else on - // this path keeps the policy the doc comment above describes; a metadata - // lock wait that the flush bounded is the operator's answer to a command - // they ran, and dropping it would report success for work not done. - if (isDeferredDdlLockWaitRefusal(err)) throw err; - this.logDurabilityFailure( - `[sql-driver] FAILED to widen MySQL datetime columns on ${table} — the DDL did not run and boot ` + - `continued: the columns are still legacy TIMESTAMP, so every write to them keeps truncating ` + - `milliseconds and keeps the 2038 ceiling, while reads return exactly what was stored and nothing ` + - `else reports the table as un-widened. Fix: identify the metadata-lock holder with ` + - '`SHOW PROCESSLIST` or `performance_schema.metadata_locks` (or resolve the error in this ' + - 'record\'s meta), end it, then re-run `os migrate apply` or restart — the widening is ' + - 're-detected from `information_schema` and is idempotent, so re-running is safe.', - { error: err instanceof Error ? err.message : String(err) }, - ); - } + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.migrateMysqlDatetimeColumns.call(rotationHost.of(this), table, fields); } /** @@ -5249,53 +5198,12 @@ export class SqlDriver implements IDataDriver { * as un-widened, so the level IS the signal. Same reasoning, same level, on * {@link migrateMysqlDatetimeColumns}. */ - protected async migrateMysqlTimeColumns( + protected migrateMysqlTimeColumns( table: string, fields: Record, ): Promise { - if (!this.isMysql) return; - try { - const legacy = await this.legacyMysqlTimeColumns(table, fields); - if (legacy.length === 0) return; - - // #9354: the datetime twin's seam, for the same reason — this ALTER takes - // the same exclusive metadata lock and blocks on the same holder. - const statements = legacy.map((col) => { - // MODIFY drops a default it does not restate. A `defaultValue: 'NOW()'` - // column gets the canonical UTC expression default (`nowColumnDefault`); - // its legacy `current_timestamp()` default read the SESSION's zone, so - // dropping-and-replacing it is a fix, not collateral. - const isNowDefault = isNowDefaultValue(fields[col.name]?.defaultValue); - const defaultClause = isNowDefault ? ' default (cast(utc_timestamp(3) as time(3)))' : ''; - return { - sql: `alter table ?? modify column ?? time(3) ${col.nullable ? 'null' : 'not null'}${defaultClause}`, - bindings: [table, col.name] as unknown[], - }; - }); - await this.runWideningAlters(table, statements); - this.logger.info?.( - `[sql-driver] widened MySQL TIME → TIME(3) on ${table}: a zero-precision TIME rounds the ` + - `canonical milliseconds to the nearest second, changing the stored wall clock`, - { columns: legacy.map((c) => c.name) }, - ); - } catch (err) { - // #9354: the ONE failure that must not be swallowed. Everything else on - // this path keeps the policy the doc comment above describes; a metadata - // lock wait that the flush bounded is the operator's answer to a command - // they ran, and dropping it would report success for work not done. - if (isDeferredDdlLockWaitRefusal(err)) throw err; - this.logDurabilityFailure( - `[sql-driver] FAILED to widen MySQL time columns on ${table} — the DDL did not run and boot ` + - `continued: the columns are still zero-precision TIME, so every fractional-second write to them ` + - `keeps being ROUNDED to whole seconds — the stored wall clock is not the one that was asked for — ` + - `while reads return exactly what was stored and nothing else reports the table as un-widened. ` + - 'Fix: identify the metadata-lock holder with `SHOW PROCESSLIST` or ' + - '`performance_schema.metadata_locks` (or resolve the error in this record\'s meta), end it, then ' + - 're-run `os migrate apply` or restart — the widening is re-detected from `information_schema` ' + - 'and is idempotent, so re-running is safe.', - { error: err instanceof Error ? err.message : String(err) }, - ); - } + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.migrateMysqlTimeColumns.call(rotationHost.of(this), table, fields); } // ── Deferred schema DDL (#3917) ──────────────────────────────────────────── @@ -7285,18 +7193,8 @@ export class SqlDriver implements IDataDriver { * shape {@link SqlDriver.withWithheldFilterLog} exists for). */ protected resolveWithheldFilterRefusal(err: unknown, rootFilter: unknown): unknown { - const diagnostic = withheldFilterDiagnosticOf(err); - if (diagnostic === null) return err; - const carrier = err as Record; - const subtree = carrier[WITHHELD_FILTER_SUBTREE]; - const provenance = - subtree === undefined ? null : resolveFilterSubtreeProvenance(rootFilter, subtree); - if (provenance === 'author') { - const authorText = carrier[WITHHELD_FILTER_AUTHOR_TEXT]; - return unsupportedFilterError(typeof authorText === 'string' ? authorText : diagnostic); - } - this.logWithheldFilterDiagnostic(err); - return err; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.resolveWithheldFilterRefusal.call(filterHelpersHost.of(this), err, rootFilter); } /** @@ -10307,6 +10205,7 @@ export class SqlDriver implements IDataDriver { get isPostgres() { return driver.isPostgres; }, get isSqlite() { return driver.isSqlite; }, get knex() { return driver.knex; }, + get logger() { return driver.logger; }, get openedEmptyInMemory() { return driver.openedEmptyInMemory; }, set openedEmptyInMemory(v) { driver.openedEmptyInMemory = v; }, get schemaMode() { return driver.schemaMode; }, @@ -10435,6 +10334,7 @@ export class SqlDriver implements IDataDriver { get isSqlite() { return driver.isSqlite; }, jsonColumnFieldClass: (...a) => driver.jsonColumnFieldClass(...a), get jsonFields() { return driver.jsonFields; }, + logWithheldFilterDiagnostic: (...a) => driver.logWithheldFilterDiagnostic(...a), get logger() { return driver.logger; }, get managedObjectFields() { return driver.managedObjectFields; }, get mediaFields() { return driver.mediaFields; }, @@ -10519,11 +10419,17 @@ export class SqlDriver implements IDataDriver { ensureRotation: (...a) => driver.ensureRotation(...a), get fractionalNumericFields() { return driver.fractionalNumericFields; }, isJsonField: (...a) => driver.isJsonField(...a), + get isMysql() { return driver.isMysql; }, get jsonFields() { return driver.jsonFields; }, get knex() { return driver.knex; }, + legacyMysqlTimeColumns: (...a) => driver.legacyMysqlTimeColumns(...a), + legacyMysqlTimestampColumns: (...a) => driver.legacyMysqlTimestampColumns(...a), + logDurabilityFailure: (...a) => driver.logDurabilityFailure(...a), + get logger() { return driver.logger; }, get managedObjectFields() { return driver.managedObjectFields; }, get managedObjectIndexes() { return driver.managedObjectIndexes; }, get mediaFields() { return driver.mediaFields; }, + nowColumnDefault: (...a) => driver.nowColumnDefault(...a), get numericFields() { return driver.numericFields; }, get numericValueFields() { return driver.numericValueFields; }, get organizationObjectRegistered() { return driver.organizationObjectRegistered; }, @@ -10531,6 +10437,7 @@ export class SqlDriver implements IDataDriver { get physicalKeyIndexes() { return driver.physicalKeyIndexes; }, registerManagedObjectMetadata: (...a) => driver.registerManagedObjectMetadata(...a), get rotationStateByTable() { return driver.rotationStateByTable; }, + runWideningAlters: (...a) => driver.runWideningAlters(...a), get supportsRotation() { return driver.supportsRotation; }, get tablesWithTimestamps() { return driver.tablesWithTimestamps; }, get tenantFieldByTable() { return driver.tenantFieldByTable; }, diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 00c47134208..8b5248ed67b 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -121,7 +121,9 @@ * this, extracting a shared reporter — normal, good refactoring — would defeat * the gate, which would make "hide the failure behind one indirection" the * cheapest way to go quiet. That is the #4420 shape itself, and it is why - * `check-init-service-contract.mjs` walks a call graph too. + * `check-init-service-contract.mjs` walks a call graph too. Moving a body into + * a `sql-driver-*.ts` facet module is the same defeat by another route, so that + * one facade's helper hop crosses the file (`FACADE_LOG_HOP`, #30147). * * ## TWO rules live in this file — this one, and the READ-SEAM rule (#5186) * @@ -2853,10 +2855,22 @@ function collectLoggedLevels(block, ctx, lineOf, seen = new Set(), depth = 0) { if (depth >= 3) return; const name = calleeName(child); if (!name || seen.has(name)) return; - const body = ctx.functionBodies.get(name, child); + let body = ctx.functionBodies.get(name, child); + let next = ctx; + let nextLineOf = lineOf; + // [#30147] A facet module's host call, or a `sql-driver.ts` stub, is + // followed across the file it sits in (`FACADE_LOG_HOP`); what is found + // there is read with THAT file's indexes, at the line of this call. + const hop = ctx.functionBodies.facadeHop?.follow(child, body, ctx.functionBodies); + if (hop) { + body = hop.body; + next = { ...ctx, functionBodies: hop.entry.bodies, logAliases: hop.entry.logAliases }; + const at = lineOf(child); + nextLineOf = () => at; + } if (!body) return; seen.add(name); - for (const l of collectLoggedLevels(body, ctx, lineOf, seen, depth + 1)) { + for (const l of collectLoggedLevels(body, next, nextLineOf, seen, depth + 1)) { levels.push({ ...l, viaHelper: name }); } }); @@ -3375,6 +3389,212 @@ function viaHostParameter(call) { return ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression) && expr.expression.text === HOST_SPLIT.hostParam; } +/** + * [#30147] The catch side's cross-file hop through the `sql-driver.ts` facade. + * + * `collectLoggedLevels` follows a catch's log helper to its body by NAME, in + * the same file. The sql-driver codemod (`scripts/codemod-split-sql-driver.mjs`, + * #30105) moves a `SqlDriver` method's body into a stateless facet class in a + * `sql-driver-*.ts` section module and leaves a one-call stub behind, so that + * lookup met the move twice, and both times read a loud catch as silent: + * + * - INTO the class. A moved body's `this.logDurabilityFailure(…)` named a + * same-file method while it lived in `sql-driver.ts`. In the facet module + * there is no such declaration, so the helper resolved nowhere and the + * catch read "swallowed, no log" (measured: `migrateMysqlDatetimeColumns` + * and `migrateMysqlTimeColumns` red when moved, #30105). + * - THROUGH a stub. Once `logDurabilityFailure`'s own body moves, the method + * the call reaches is `return ClassHeadFacet.logDurabilityFailure.call( + * classHeadHost.of(this), msg, meta)`, which logs nothing itself. + * + * The codemod kept both on the class (a pin and a needle) until this hop + * existed. It follows exactly the two shapes the codemod writes, and nothing + * else: + * + * HOST CALL — `this.x(…)` in a `sql-driver-*.ts` module of this directory, + * inside a function whose `this` parameter is typed by an interface the SAME + * module declares (bare, or through `Pick` naming `x`), whose + * member `x` is typed `['x']`, `T` a name the module imports for the class + * `SqlDriver` from `./sql-driver.js` (a routed module imports it AS + * `SqlDriverClass`), resolves to `SqlDriver`'s own instance method `x` in + * `sql-driver.ts`. ONE hop, the hop the same call made before the body moved. + * + * STUB — a `SqlDriver` instance method whose LAST statement returns + * `Facet.x.call(Host.of(this), …)` (awaited or not), `Facet` a named + * value import from a `./sql-driver-*.js` module that exports the class with + * one static method `x`, is read as that method's body. Not a hop: it is the + * same method's body, relocated, so it costs no depth. + * + * Every refusal (another directory, a module the pattern does not name, a + * `this` typed by anything but the module's own host interface, a member + * typed any other way, a stub handing its body anything but `Host.of(this)`) + * leaves the helper unresolved: the under-counting direction this file takes + * everywhere. A log found across the hop is reported at the line of the call + * that reached it, in the file being judged. + * + * The log-level rule only: the read-seam rule's scan roots + * (`READ_SEAM_SCAN_ROOTS`) do not hold this directory, so the two rules still + * agree on every file both of them read. + */ +const FACADE_LOG_HOP = Object.freeze({ + dir: 'packages/drivers/driver-sql/src', + host: 'sql-driver.ts', + hostClass: 'SqlDriver', + hostSpecifier: './sql-driver.js', + module: /^sql-driver-[a-z0-9-]+\.ts$/, + specifier: /^\.\/(sql-driver-[a-z0-9-]+)\.js$/, + facet: /^[A-Z][A-Za-z0-9]*Facet$/, + hostOf: /^[a-z][A-Za-z0-9]*Host$/, +}); + +/** + * The hop above, over one set of sources. `load(relPath)` answers a file's + * text (or `undefined`); each file it reads is parsed once. + * + * @returns `{ attach(bodies, relPath), follow(call, body, bodies) }`. + * `follow` answers `{ body, entry }` (the body to read and the file + * it lives in, `entry.bodies` / `entry.logAliases`) or `undefined` + * when the call is not one of the two shapes. + */ +function createFacadeLogHop(load, config = FACADE_LOG_HOP) { + const hostRel = `${config.dir}/${config.host}`; + const isModule = (rel) => + typeof rel === 'string' && + rel.startsWith(`${config.dir}/`) && + !rel.slice(config.dir.length + 1).includes('/') && + config.module.test(rel.slice(config.dir.length + 1)); + const cache = new Map(); + const entry = (rel) => { + if (cache.has(rel)) return cache.get(rel); + let made; + const text = rel === hostRel || isModule(rel) ? load(rel) : undefined; + if (text !== undefined) { + const sf = parseSourceFile(rel, text, ts.ScriptKind.TS); + const bodies = indexFunctionBodies(sf); + attach(bodies, rel); + made = { sf, bodies, logAliases: indexLogAliases(sf, bodies) }; + } + cache.set(rel, made); + return made; + }; + const attach = (bodies, rel) => { + if (rel === hostRel || isModule(rel)) { + bodies.facadeHop = api; + bodies.relPath = rel; + } + return bodies; + }; + const exported = (s) => !!s.modifiers?.some((x) => x.kind === ts.SyntaxKind.ExportKeyword); + const isStatic = (m) => !!m.modifiers?.some((x) => x.kind === ts.SyntaxKind.StaticKeyword); + /** The one method `name` of class `cls` in `sf` with a body (`static` as asked), or `undefined`. */ + const classMethod = (sf, cls, name, wantStatic) => { + const c = sf.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === cls); + const ms = c?.members.filter((m) => ts.isMethodDeclaration(m) && m.body && ts.isIdentifier(m.name) && + m.name.text === name && isStatic(m) === wantStatic) ?? []; + return ms.length === 1 ? { cls: c, body: ms[0].body } : undefined; + }; + /** Named imports of `sf` from `pick(specifier)`: local name → imported name (`value` when not type-only). */ + const importsOf = (sf, pick) => { + const out = new Map(); + for (const stmt of sf.statements) { + if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; + const rel = pick(stmt.moduleSpecifier.text); + const named = stmt.importClause?.namedBindings; + if (rel === undefined || !named || !ts.isNamedImports(named)) continue; + for (const el of named.elements) { + out.set(el.name.text, { + rel, + imported: (el.propertyName ?? el.name).text, + value: !stmt.importClause.isTypeOnly && !el.isTypeOnly, + }); + } + } + return out; + }; + + /** HOST CALL: `this.x(…)` in a facet module → `SqlDriver#x`'s body in the host file. */ + const hostCall = (call, bodies) => { + if (!isModule(bodies.relPath)) return undefined; + const expr = call.expression; + if (!ts.isPropertyAccessExpression(expr) || !ts.isIdentifier(expr.name) || expr.expression.kind !== ts.SyntaxKind.ThisKeyword) return undefined; + const name = expr.name.text; + let fn = call.parent; + while (fn && (ts.isArrowFunction(fn) || !ts.isFunctionLike(fn))) fn = fn.parent; + const thisParam = fn?.parameters?.[0]; + if (!thisParam || !ts.isIdentifier(thisParam.name) || thisParam.name.text !== 'this') return undefined; + let type = thisParam.type; + if (!type || !ts.isTypeReferenceNode(type) || !ts.isIdentifier(type.typeName)) return undefined; + if (type.typeName.text === 'Pick') { + const [target, keys] = type.typeArguments ?? []; + const picked = (k) => ts.isLiteralTypeNode(k) && ts.isStringLiteral(k.literal) && k.literal.text === name; + const listed = keys && (ts.isUnionTypeNode(keys) ? keys.types.some(picked) : picked(keys)); + if (!listed || !target || !ts.isTypeReferenceNode(target) || !ts.isIdentifier(target.typeName)) return undefined; + type = target; + } + const sf = call.getSourceFile(); + const iface = sf.statements.find((s) => ts.isInterfaceDeclaration(s) && s.name.text === type.typeName.text); + const member = iface?.members.find((m) => ts.isPropertySignature(m) && m.name && ts.isIdentifier(m.name) && m.name.text === name); + const t = member?.type; + if (!t || !ts.isIndexedAccessTypeNode(t) || !ts.isTypeReferenceNode(t.objectType) || !ts.isIdentifier(t.objectType.typeName) + || !ts.isLiteralTypeNode(t.indexType) || !ts.isStringLiteral(t.indexType.literal) || t.indexType.literal.text !== name) return undefined; + const classImport = importsOf(sf, (s) => (s === config.hostSpecifier ? hostRel : undefined)).get(t.objectType.typeName.text); + if (!classImport || classImport.imported !== config.hostClass) return undefined; + const host = entry(hostRel); + const hit = host && classMethod(host.sf, config.hostClass, name, false); + return hit ? { body: hit.body, entry: host } : undefined; + }; + + /** STUB: a `SqlDriver` method whose last statement is `return Facet.x.call(Host.of(this), …)` → the facet's static `x`. */ + const stubTarget = (body) => { + const method = body?.parent; + if (!method || !ts.isBlock(body) || !ts.isMethodDeclaration(method) || !ts.isIdentifier(method.name) || isStatic(method)) return undefined; + const cls = method.parent; + if (!ts.isClassDeclaration(cls) || cls.name?.text !== config.hostClass) return undefined; + const name = method.name.text; + const last = body.statements[body.statements.length - 1]; + if (!last || !ts.isReturnStatement(last) || !last.expression) return undefined; + let call = last.expression; + if (ts.isAwaitExpression(call)) call = call.expression; + if (!ts.isCallExpression(call)) return undefined; + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.name) || callee.name.text !== 'call') return undefined; + const member = callee.expression; + if (!ts.isPropertyAccessExpression(member) || !ts.isIdentifier(member.name) || member.name.text !== name + || !ts.isIdentifier(member.expression) || !config.facet.test(member.expression.text)) return undefined; + const first = call.arguments[0]; + const hostOfThis = !!first && ts.isCallExpression(first) && ts.isPropertyAccessExpression(first.expression) && + ts.isIdentifier(first.expression.name) && first.expression.name.text === 'of' && + ts.isIdentifier(first.expression.expression) && config.hostOf.test(first.expression.expression.text) && + first.arguments.length === 1 && first.arguments[0].kind === ts.SyntaxKind.ThisKeyword; + if (!hostOfThis) return undefined; + const facetImport = importsOf(body.getSourceFile(), (s) => { + const m = config.specifier.exec(s); + return m ? `${config.dir}/${m[1]}.ts` : undefined; + }).get(member.expression.text); + if (!facetImport || !facetImport.value) return undefined; + const mod = entry(facetImport.rel); + const hit = mod && classMethod(mod.sf, facetImport.imported, name, true); + return hit && exported(hit.cls) ? { body: hit.body, entry: mod } : undefined; + }; + + const follow = (call, body, bodies) => { + let at; + if (!body) { + const h = hostCall(call, bodies); + if (!h) return undefined; + ({ body, entry: at } = h); + } + if ((at ? at.bodies.relPath : bodies.relPath) === hostRel) { + const s = stubTarget(body); + if (s) ({ body, entry: at } = s); + } + return at ? { body, entry: at } : undefined; + }; + + const api = { attach, follow, entry }; + return api; +} + /** * Does this call perform a storage READ — directly, or through a same-file * wrapper? See `MAX_READ_WRAPPER_DEPTH` for why wrappers are followed, and @@ -3778,6 +3998,8 @@ function analyzeSourceFile(sf, relPath, findings, seams, options = {}) { const usedPropagationSites = options.usedPropagationSites; const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; const functionBodies = indexFunctionBodies(sf); + // [#30147] The facade hop, when the run hands one in (`FACADE_LOG_HOP`). + if (options.facadeHop) options.facadeHop.attach(functionBodies, relPath); const logAliases = indexLogAliases(sf, functionBodies); // #9748: the summary-limb census. Collected even when the caller does not // ask for it, so the limb's code path is identical in every run. @@ -4566,6 +4788,10 @@ function run({ list = false } = {}) { const seams = []; const summaryBranches = []; const usedPropagationSites = new Set(); + const facadeHop = createFacadeLogHop((rel) => { + const abs = join(ROOT, rel); + return existsSync(abs) ? readFileSync(abs, 'utf8') : undefined; + }); for (const file of files) { const text = readFileSync(file, 'utf8'); @@ -4574,6 +4800,7 @@ function run({ list = false } = {}) { analyzeSourceFile(sf, relative(ROOT, file).split(sep).join('/'), findings, seams, { usedPropagationSites, summaryBranches, + facadeHop, }); } @@ -4994,6 +5221,16 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'flags: #22985 — `.apply(recv, args)` and `.bind(recv)(…)` invoke their target too': 1, 'passes: #22985 — a bare `.bind(recv)` invokes nothing, so it guards nothing': 1, 'passes: #22985 — a shadowing parameter or a `let` binding is not resolved as an alias': 1, + 'passes: #30147 — a facet module\'s catch delegating to `this.logDurabilityFailure()` follows the host call and the stub to a loud body': 1, + 'flags: #30147 — the same hop to a body that only warns is judged at that level': 1, + 'flags: #30147 — negative control: with no hop handed in, the moved catch reads swallowed': 1, + 'flags: #30147 — the same files outside packages/drivers/driver-sql/src resolve nowhere': 1, + 'flags: #30147 — a host member typed any way but AS the class member resolves nowhere': 1, + 'flags: #30147 — a member the function\'s `Pick` does not name resolves nowhere': 1, + 'flags: #30147 — a class type not imported from ./sql-driver.js resolves nowhere': 1, + 'passes: #30147 — a routed module\'s member typed through the class imported AS an alias resolves': 1, + 'passes: #30147 — a sql-driver.ts catch whose helper is a stub follows the stub to the facet body': 1, + 'flags: #30147 — a stub handing its body anything but `Host.of(this)` is not followed': 1, 'ledger: a stale baseline entry PASSES and is reported; an unledgered finding still FAILS (#26444)': 1, 'ledger: a ledgered file on disk the scan never READ is refused; a deleted one passes (#26444)': 1, }), @@ -5093,7 +5330,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // added to the roster with no size pin at all: the lookup below reads // `undefined` and the comparison fails loudly. const SELF_TEST_BATTERY_FLOOR = Object.freeze({ - selfTest: 70, + selfTest: 80, selfTestReadSeams: 73, }); @@ -5102,7 +5339,7 @@ const SELF_TEST_BATTERY_FLOOR = Object.freeze({ // `selfTestReadSeams`'s partition being deleted from the roster wholesale — the // deleted partition's own size pin is not evaluated by anyone once its entry // stops reaching it. This is the one assertion that survives that deletion. -const SELF_TEST_BATTERY_TOTAL_FLOOR = 137; +const SELF_TEST_BATTERY_TOTAL_FLOOR = 147; /** * The floor for ONE partition of the roster, run from that partition's own @@ -6343,6 +6580,99 @@ function selfTest() { console.log(` ✓ ${row}`); } } + // ── [#30147] The facade hop (`FACADE_LOG_HOP`), over multi-file fixtures ── + // + // Driven through `analyzeSourceFile` exactly as `run()` drives it, with the + // hop built over the fixture files instead of the disk. Each row is one + // battery; the controls show each refusal is what reds its row. + { + const D = FACADE_LOG_HOP.dir; + const host = ({ stubArg = 'classHeadHost.of(this)', catchSite = false } = {}) => `import { ClassHeadFacet } from './sql-driver-class-head.js'; +import { classHeadHost } from './sql-driver-facet-hosts.js'; +export class SqlDriver { + protected logger: any = console; + protected logDurabilityFailure(msg: string, meta?: any): void { + return ClassHeadFacet.logDurabilityFailure.call(${stubArg}, msg, meta); + } + protected async runWideningAlters(table: string, statements: unknown[]): Promise {} +${catchSite ? ` protected async migrate(table: string): Promise { + try { + await this.runWideningAlters(table, []); + } catch (err) { + this.logDurabilityFailure('widening failed', { err }); + } + } +` : ''}} +`; + const head = (level = 'error') => `import type { SqlDriver } from './sql-driver.js'; +export interface ClassHeadHost { + logger: SqlDriver['logger']; +} +export class ClassHeadFacet { + static logDurabilityFailure(this: Pick, msg: string, meta?: any): void { + this.logger.${level}(msg, meta); + } +} +`; + const rotation = ({ cls = 'SqlDriver', imp = "import type { SqlDriver } from './sql-driver.js';", type, pick = "'logDurabilityFailure' | 'runWideningAlters'" } = {}) => `${imp} +export interface RotationHost { + logDurabilityFailure: ${type ?? `${cls}['logDurabilityFailure']`}; + runWideningAlters: ${cls}['runWideningAlters']; +} +export class RotationFacet { + static async migrate(this: Pick, table: string): Promise { + try { + await this.runWideningAlters(table, []); + } catch (err) { + this.logDurabilityFailure('widening failed', { err }); + } + } +} +`; + const judge = ({ files, rel, dir = D, hop = true }) => { + const texts = new Map(Object.entries(files).map(([f, t]) => [`${dir}/${f}`, t])); + const findings = []; + const seams = []; + const sf = parseSourceFile(`${dir}/${rel}`, texts.get(`${dir}/${rel}`), ts.ScriptKind.TS); + analyzeSourceFile(sf, `${dir}/${rel}`, findings, seams, hop ? { facadeHop: createFacadeLogHop((r) => texts.get(r)) } : {}); + return { findings: findings.length, seams: seams.length }; + }; + const moved = (over = {}) => ({ + files: { 'sql-driver.ts': host(), 'sql-driver-class-head.ts': head(), 'sql-driver-rotation.ts': rotation(), ...over }, + rel: 'sql-driver-rotation.ts', + }); + const rows = [ + ['passes: #30147 — a facet module\'s catch delegating to `this.logDurabilityFailure()` follows the host call and the stub to a loud body', + judge(moved()), 0], + ['flags: #30147 — the same hop to a body that only warns is judged at that level', + judge(moved({ 'sql-driver-class-head.ts': head('warn') })), 1], + ['flags: #30147 — negative control: with no hop handed in, the moved catch reads swallowed', + judge({ ...moved(), hop: false }), 1], + ['flags: #30147 — the same files outside packages/drivers/driver-sql/src resolve nowhere', + judge({ ...moved(), dir: 'packages/drivers/driver-other/src' }), 1], + ['flags: #30147 — a host member typed any way but AS the class member resolves nowhere', + judge(moved({ 'sql-driver-rotation.ts': rotation({ type: '(msg: string, meta?: any) => void' }) })), 1], + ['flags: #30147 — a member the function\'s `Pick` does not name resolves nowhere', + judge(moved({ 'sql-driver-rotation.ts': rotation({ pick: "'runWideningAlters'" }) })), 1], + ['flags: #30147 — a class type not imported from ./sql-driver.js resolves nowhere', + judge(moved({ 'sql-driver-rotation.ts': rotation({ imp: "import type { SqlDriver } from './other.js';" }) })), 1], + ['passes: #30147 — a routed module\'s member typed through the class imported AS an alias resolves', + judge(moved({ 'sql-driver-rotation.ts': rotation({ cls: 'SqlDriverClass', imp: "import type { SqlDriver as SqlDriverClass } from './sql-driver.js';" }) })), 0], + ['passes: #30147 — a sql-driver.ts catch whose helper is a stub follows the stub to the facet body', + judge({ files: { 'sql-driver.ts': host({ catchSite: true }), 'sql-driver-class-head.ts': head() }, rel: 'sql-driver.ts' }), 0], + ['flags: #30147 — a stub handing its body anything but `Host.of(this)` is not followed', + judge({ files: { 'sql-driver.ts': host({ catchSite: true, stubArg: 'this' }), 'sql-driver-class-head.ts': head() }, rel: 'sql-driver.ts' }), 1], + ]; + for (const [row, got, want] of rows) { + registerCase(row); + if (got.seams !== 1 || got.findings !== want) { + failures++; + console.error(` ✗ ${row}: expected 1 seam and ${want} finding(s), got ${got.seams} seam(s) and ${got.findings} finding(s)`); + } else { + console.log(` ✓ ${row}`); + } + } + } failures += checkSelfTestFloor( 'selfTest', cases.map((c) => c.name), diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index 89249b5a767..a608e4b947e 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -249,11 +249,6 @@ const PATH_READER_PINS = Object.freeze({ createColumn: 'packages/cli generate-{field-type-vocabulary,string-family-width,multiple-json-column} pins and ' + 'scripts/check-keyed-text-bounds.mjs slice `protected createColumn(` to its type switch', createAuditTimestampColumn: 'packages/cli generate-builtin-id-column pin reads 600 characters after its signature', - // The durability catches that stay (the `this.runWideningAlters(` row - // below) are judged by following `this.logDurabilityFailure(…)` to its - // body in the same file; a one-call stub there is a call that gate cannot - // follow, so both catches read "swallowed, no log" (measured, #30105). - logDurabilityFailure: 'scripts/check-durability-degradation-log-level.mjs follows a catch\'s log helper to its body in the same file', }); /** @@ -276,12 +271,6 @@ const PATH_READER_NEEDLES = Object.freeze({ 'keyed ? this.keyableTextLength(field) : null': 'scripts/check-keyed-text-bounds.mjs EMITTER_ANCHOR and the generate-string-family-width pin', 'JSON_COLUMN_TYPES.has(type) ? this.jsonColumn(table, name) : table.string(name)': 'packages/cli generate-field-type-vocabulary and generate-string-family-width pins', 'if (FILE_REFERENCE_TYPES.has(type)) {': 'packages/cli generate-string-family-width pin', - // check-durability-degradation-log-level judges the `catch` around a - // durability call by following SAME-FILE helpers (its `functionBodies` is - // per source file): `this.logDurabilityFailure(…)` resolves only in - // sql-driver.ts, so a moved catch reads as "swallowed, no log" (measured: - // migrateMysqlDatetimeColumns / migrateMysqlTimeColumns red when moved). - 'this.runWideningAlters(': 'scripts/check-durability-degradation-log-level.mjs resolves the catch\'s log helper in the same file', }); /** @@ -290,20 +279,15 @@ const PATH_READER_NEEDLES = Object.freeze({ * class; a top-level declaration carrying one moves as any other, because the * reader reads the leaf it moves to. The same stale-needle refusal applies. * - * All three rows are `sql-driver-compile-refusal-seam.test.ts`'s enumeration: - * it parses `sql-driver.ts`, the leaf and three named seam modules, and asserts - * the EXACT set of functions that call `unsupportedFilterError` / - * `withheldFilterError` or write the `INVALID_FILTER` code, by enclosing name. - * Its `sources` gained the leaf by ruling 6597555122 (#29520 round 3, the path - * reader migrated in the same PR as #25521 ask 3 asks), so these rows left - * `PATH_READER_NEEDLES`. A method still stays: its body would move to a section - * module the test does not read, and `SqlDriver.resolveWithheldFilterRefusal`, - * a name the test pins, would drop out of the set. + * Empty since #30147: its three rows were `sql-driver-compile-refusal-seam.test.ts`'s + * enumeration of the functions that call `unsupportedFilterError` / + * `withheldFilterError` or write the `INVALID_FILTER` code. That test now reads + * every `sql-driver-*.ts` module and names a facet's static `x` as + * `SqlDriver.x` (ruling 6597555122 item 3: the reader gains the new modules in + * the same PR), so `resolveWithheldFilterRefusal` moves. The table stays for + * the next reader of `sql-driver.ts` and the leaf only. */ const CLASS_PATH_READER_NEEDLES = Object.freeze({ - 'unsupportedFilterError(': 'sql-driver-compile-refusal-seam.test.ts enumerates its callers (it reads the leaf, no section module)', - 'withheldFilterError(': 'sql-driver-compile-refusal-seam.test.ts enumerates its callers (it reads the leaf, no section module)', - '.INVALID_FILTER': 'sql-driver-compile-refusal-seam.test.ts enumerates the INVALID_FILTER code writers (it reads the leaf, no section module)', }); /** diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 41c20471d3f..6848cd51710 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -389,12 +389,6 @@ "card": "#30147", "reason": "codemod limit: a static initializer runs while the class itself is evaluated, so a call into a facet module there could meet that module in its temporal dead zone (MOVED_FIELD_DEFAULTS takes instance fields only, #30147 phase 2a)" }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "logDurabilityFailure", - "card": "#30147", - "reason": "path-reader (#30105 report: check-durability-degradation-log-level follows a catch's log helper to its body in the same file)" - }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "member": "get supports", @@ -413,24 +407,6 @@ "card": "#30147", "reason": "path-reader (#30105 report: packages/cli generate-* pins read its text in sql-driver.ts); its field-call: knex half is retired (#30147 phase 2a)" }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "migrateMysqlDatetimeColumns", - "card": "#30147", - "reason": "path-reader (#30105 report: check-durability-degradation-log-level resolves its catch's log helper in the same file)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "migrateMysqlTimeColumns", - "card": "#30147", - "reason": "path-reader (#30105 report: check-durability-degradation-log-level resolves its catch's log helper in the same file)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "resolveWithheldFilterRefusal", - "card": "#30147", - "reason": "path-reader (#30105 report: sql-driver-compile-refusal-seam.test.ts enumerates the unsupportedFilterError callers)" - }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "member": "createAuditTimestampColumn",