From 34fc09d0d0b6218425272b6b50530dbe31b1308a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:26:12 +0000 Subject: [PATCH] refactor(driver-sql): the sql-driver codemod retires 15 facade-gate exception rows (knex calls, spelled return types, comments in parameters, accessors, computed defaults, the logger default) (#30147 phase 2a) The codemod (scripts/codemod-split-sql-driver.mjs) learns six mechanisms, each proved by --verify (undoing the split rebuilds the base byte for byte): - RECEIVER_FREE_FIELDS: a body may call `this.knex(...)`; a Knex instance is a closure that never reads `this`, so the host's getter keeps the call. - EXPLICIT_RETURN_TYPES: four stubs write the return type the base's declaration output prints (copied from dist/index.d.ts built at base). - A comment between a method's parentheses moves with the base list verbatim. - MOVED_ACCESSORS: four get accessors keep their signature; the body moves. - A computed parameter default (`nowMs = Date.now()`) is written optional on the stub (`nowMs?: number`) and stays where the body is, a seam function for the two hand-series stubs. - MOVED_FIELD_DEFAULTS: the `logger` field's initializer becomes a facet member typed as the field. sql-driver.ts 10,979 -> 10,717 lines; 171/171 moved bodies identical to base; public .d.ts byte-identical. 15 sql-driver.ts registry rows deleted (88 -> 73); five reasons rewritten for the halves that remain. check-tenant-chokepoint declares the new sql-driver-schema-introspection.ts. Claude-Session: https://claude.ai/code/session_01WGufzj4B2cbnRLVdgtLcDZ Co-authored-by: Claude --- .../driver-sql/src/sql-driver-class-head.ts | 43 +- .../src/sql-driver-column-creation.ts | 182 +++++- .../src/sql-driver-database-helpers.ts | 139 ++++- .../driver-sql/src/sql-driver-facet-hosts.ts | 2 + .../src/sql-driver-filter-helpers.ts | 55 +- .../src/sql-driver-internal-helpers.ts | 28 +- .../driver-sql/src/sql-driver-lifecycle.ts | 13 +- .../src/sql-driver-managed-drift.ts | 12 +- .../src/sql-driver-schema-introspection.ts | 59 ++ packages/drivers/driver-sql/src/sql-driver.ts | 396 +++---------- scripts/check-tenant-chokepoint.mjs | 1 + scripts/codemod-split-sql-driver.mjs | 521 ++++++++++++++++-- scripts/facade-delegation-registry.json | 100 +--- 13 files changed, 1052 insertions(+), 499 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-schema-introspection.ts 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 7e885cbefbe..fbed04a74e0 100644 --- a/packages/drivers/driver-sql/src/sql-driver-class-head.ts +++ b/packages/drivers/driver-sql/src/sql-driver-class-head.ts @@ -2,9 +2,10 @@ /** * [#29520 codemod] The "Class head: dialect probes, logger, configuration" - * section of `SqlDriver`: the bodies of `emissionClients`, `clientSpelling`, - * `aliasIdentifierSql`, `dateBucketSql`, `knexConfigFor`, `withConnectBound`, - * `withUtcSession`, `withMysqlCalendarDayAsText`, + * 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`. @@ -60,6 +61,8 @@ export interface ClassHeadHost { assertDialectEmits: SqlDriverClass['assertDialectEmits']; buildDateBucketExpr: SqlDriverClass['buildDateBucketExpr']; readonly config: SqlDriverClass['config']; + readonly isMysql: SqlDriverClass['isMysql']; + readonly isPostgres: SqlDriverClass['isPostgres']; readonly isSqlite: SqlDriverClass['isSqlite']; readonly knex: SqlDriverClass['knex']; openedEmptyInMemory: SqlDriverClass['openedEmptyInMemory']; @@ -68,6 +71,14 @@ export interface ClassHeadHost { /** The bodies of the "Class head: dialect probes, logger, configuration" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ export class ClassHeadFacet { + /** The initializer of `SqlDriver#logger`; the field's declaration there is its contract. */ + static logger(): SqlDriverClass['logger'] { + return { + warn: (msg, meta) => console.warn(msg, meta ?? ''), + error: (msg, meta) => console.error(msg, meta ?? ''), + }; + } + /** The body of `SqlDriver#emissionClients`; the method's docblock there is its contract. */ static emissionClients(): string[] { return [ @@ -90,11 +101,37 @@ export class ClassHeadFacet { return typeof c === 'string' ? c : ''; } + /** The body of `SqlDriver#insertReturnsStoredRows`; the method's docblock there is its contract. */ + static insertReturnsStoredRows(this: Pick): boolean { + return this.isSqlite || this.isPostgres; + } + /** The body of `SqlDriver#aliasIdentifierSql`; the method's docblock there is its contract. */ static aliasIdentifierSql(this: Pick, alias: string): string { return (this.knex.client as { wrapIdentifier(value: string): string }).wrapIdentifier(String(alias)); } + /** The body of `SqlDriver#dateGranularityCapabilities`; the method's docblock there is its contract. */ + static dateGranularityCapabilities(this: Pick): Record { + if (this.isPostgres) { + return { day: true, month: true, quarter: true, year: true, week: true }; + } + if (this.isMysql) { + return { day: true, month: true, quarter: true, year: true, week: true }; + } + if (this.isSqlite) { + // [#21595] All five, `week` included. The ISO week is computed by the + // Thursday rule through date modifiers (the `week` arm of + // `buildDateBucketExpr`), never `strftime('%V')` / `'%G'`: those arrived + // in SQLite 3.46, and libSQL (`driver-turso`) bundles an older engine. + // Measured: better-sqlite3 13.0.3 runs 3.53.4, `@libsql/client` 0.18.0 + // runs 3.45.1 (where `%V` answers NULL), sql.js 1.14.2 + // (`driver-sqlite-wasm`) runs 3.49.1. The expression runs on all three. + return { day: true, month: true, quarter: true, year: true, week: true }; + } + return {}; + } + /** The body of `SqlDriver#dateBucketSql`; the method's docblock there is its contract. */ static dateBucketSql(this: Pick, objectName: string, field: string, granularity: DateGranularityValue): string | null { const bucket = this.buildDateBucketExpr(field, granularity, objectName); diff --git a/packages/drivers/driver-sql/src/sql-driver-column-creation.ts b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts index f124964b669..227e45d2dec 100644 --- a/packages/drivers/driver-sql/src/sql-driver-column-creation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts @@ -3,8 +3,9 @@ /** * [#29520 codemod] The "Column creation helper" section of `SqlDriver`: the * bodies of `declaredVarcharLength`, `keyableTextLength`, - * `hashShadowColumnFor`, `createHashShadowUniqueIndex`, - * `hashShadowColumnState`, `dropOrphanedHashShadowColumn`, + * `explainUnkeyableTextColumn`, `hashShadowColumnFor`, + * `createHashShadowUniqueIndex`, `hashShadowColumnState`, + * `dropOrphanedHashShadowColumn`, `explainHashShadowDuplicate`, * `decorateHashShadowDuplicate`, `hashShadowSourceColumns`, * `varcharPackLength`, `rowWidthProfile`, `schemaBytesPerChar`, * `explainRowSizeOverflow`, `rethrowWithRowSizeExplanation`, `jsonColumn` moved @@ -55,11 +56,14 @@ import type { SqlDriver as SqlDriverClass } from './sql-driver.js'; * assigns. */ export interface ColumnCreationHost { + declaredFieldsFor: SqlDriverClass['declaredFieldsFor']; explainHashShadowDuplicate: SqlDriverClass['explainHashShadowDuplicate']; explainRowSizeOverflow: SqlDriverClass['explainRowSizeOverflow']; hashShadowColumnState: SqlDriverClass['hashShadowColumnState']; + hashShadowSourceColumns: SqlDriverClass['hashShadowSourceColumns']; readonly isMysql: SqlDriverClass['isMysql']; readonly isSqlite: SqlDriverClass['isSqlite']; + keyableTextLength: SqlDriverClass['keyableTextLength']; readonly knex: SqlDriverClass['knex']; logDurabilityFailure: SqlDriverClass['logDurabilityFailure']; readonly logger: SqlDriverClass['logger']; @@ -89,6 +93,101 @@ export class ColumnCreationFacet { return n; } + /** The body of `SqlDriver#explainUnkeyableTextColumn`; the method's docblock there is its contract. */ + static async explainUnkeyableTextColumn(this: Pick, tableName: string, indexName: string, columns: string[], cause: unknown): Promise { + const code = (cause as { code?: string } | undefined)?.code; + if (code !== 'ER_BLOB_KEY_WITHOUT_LENGTH' && code !== 'ER_TOO_LONG_KEY') return null; + let offenders: string[] = []; + try { + const info: Record = await this.knex(tableName).columnInfo(); + offenders = columns.filter((c) => /text|blob/i.test(String(info?.[c]?.type ?? ''))); + } catch { + // Introspection is a nicety here; the advice below holds without it. + } + const named = + offenders.length > 0 + ? `Column(s) ${offenders.map((c) => `"${c}"`).join(', ')} are stored as TEXT` + : 'One or more of its key columns is stored as TEXT'; + + // Commit ebcc34e89: the SAME refusal has two causes, and the message below is true of + // only one of them. On CREATE the field really declares no usable bound, so + // "declare `maxLength`" is the fix. On an UPGRADE the bound IS declared — + // the additive sync never rewrites a column's type (#3728), so a column + // created before the declaration stays TEXT forever — and that same sentence + // tells the operator to do what they already did, once per boot, in + // production, which reads as the release they just deployed being broken. + // + // The discriminator is per COLUMN and needs BOTH halves, which is why it is + // computed here rather than inferred from the error code: a bounded + // declaration alone does not imply a stale column, because `ER_TOO_LONG_KEY` + // can refuse a COMPOSITE whose parts are each individually keyable (see + // {@link MAX_KEYABLE_VARCHAR_CHARS} — the bound is per KEY, not per column). + // So the branch fires only where the physical column is TEXT *and* + // {@link keyableTextLength} says a fresh create would have emitted + // `varchar(n)` for it. When `columnInfo()` fails, `offenders` is empty and + // this map stays empty, so the CREATE-path message stands — the honest + // reading, since without introspection the two causes are indistinguishable. + // + // `declaredFieldsFor` returns `undefined` for a table this driver does not + // own the column set of, and misses a SHARD table (registered under the base + // name). Both degrade to the message below rather than guessing. + const declaredFields = this.declaredFieldsFor(tableName); + const staleBounds = new Map(); + for (const c of offenders) { + const field = declaredFields?.[c]; + const bound = field ? this.keyableTextLength(field) : null; + if (bound !== null) staleBounds.set(c, bound); + } + + if (staleBounds.size > 0) { + const staleNamed = [...staleBounds] + .map(([c, n]) => `"${c}" (declares \`maxLength: ${n}\`)`) + .join(', '); + const [firstColumn, firstBound] = [...staleBounds][0]; + // Named in full, deliberately. The mirror case in `schema-drift.ts` (the + // unbounded field over a stale `varchar`) already had to derive this, and + // the two MySQL `MODIFY` hazards it names are recorded twice more in this + // file, on the datetime and time widenings: MODIFY does NOT repeat a + // `NOT NULL`, and it DROPS a `DEFAULT` it does not restate. An operator + // who follows an abbreviated version of this remedy ends up worse off than + // the no-op they started with — a column that lost its default. + const others = + offenders.length > staleBounds.size + ? ` Column(s) ${offenders + .filter((c) => !staleBounds.has(c)) + .map((c) => `"${c}"`) + .join(', ')} in the same key declare no usable bound and DO need \`maxLength\` ` + + `declared on the field.` + : ''; + return ( + `[sql-driver] cannot create index '${indexName}' on "${tableName}" — MySQL refuses a TEXT/BLOB ` + + `column in a key without a key length. Column(s) ${staleNamed} are stored as TEXT even though the ` + + `field ALREADY declares a usable \`maxLength\` — the column was created before that bound was ` + + `declared, and the additive sync never rewrites a column's type, so re-declaring \`maxLength\` ` + + `changes nothing here. REMEDY: convert the column(s) to varchar(n) by hand, with a backup ` + + `taken first, restating the FULL column definition on MySQL (MODIFY drops a NOT NULL or DEFAULT you ` + + `do not repeat) — e.g. ALTER TABLE \`${tableName}\` MODIFY \`${firstColumn}\` ` + + `varchar(${firstBound}) plus that column's existing NOT NULL / DEFAULT clauses, restated verbatim; ` + + `— and let the next boot create this index. ObjectStack does NOT rewrite the column for you: that ` + + `ALTER needs an exclusive metadata lock on the table, which makes it a destructive, hard-to-roll-back ` + + `action and a deliberate manual floor rather than a boot-time one.${others} The table exists but this ` + + `index does NOT, so any uniqueness it declared is currently unenforced. A prefix index is deliberately ` + + `not substituted: on a UNIQUE index it constrains the prefix rather than the value, and rejects two ` + + `different values that share one.` + ); + } + + return ( + `[sql-driver] cannot create index '${indexName}' on "${tableName}" — MySQL refuses a TEXT/BLOB ` + + `column in a key without a key length. ${named} because the field declares no \`maxLength\` (or one ` + + `wider than ${SqlDriver.MAX_KEYABLE_VARCHAR_CHARS} characters, the most a utf8mb4 key part can hold). ` + + `Declare \`maxLength\` on the field(s) so the column is emitted as varchar(n) and can be keyed. ` + + `The table exists but this index does NOT, so any uniqueness it declared is currently ` + + `unenforced. A prefix index is deliberately not substituted: on a UNIQUE index it constrains the ` + + `prefix rather than the value, and rejects two different values that share one.` + ); + } + /** The body of `SqlDriver#hashShadowColumnFor`; the method's docblock there is its contract. */ static hashShadowColumnFor(indexName: string): string { // Commit cd1348802: DELEGATES rather than re-deriving. The differ has to look for @@ -277,6 +376,85 @@ export class ColumnCreationFacet { return true; } + /** The body of `SqlDriver#explainHashShadowDuplicate`; the method's docblock there is its contract. */ + static async explainHashShadowDuplicate(this: Pick, tableName: string, values: Record, cause: unknown): Promise { + if (!this.isMysql) return null; + if ((cause as { code?: string } | undefined)?.code !== 'ER_DUP_ENTRY') return null; + const message = String((cause as { message?: string } | undefined)?.message ?? ''); + // MySQL names the key as `table.index` (8.0) or `index` (5.7) — take the + // last path segment either way. + const keyed = /for key '([^']+)'/.exec(message); + if (!keyed) return null; + const indexName = keyed[1]!.split('.').pop()!; + const shadow = SqlDriver.hashShadowColumnFor(indexName); + let shadowExists = false; + try { + shadowExists = await this.knex.schema.hasColumn(tableName, shadow); + } catch { + return null; + } + if (!shadowExists) return null; + const sources = await this.hashShadowSourceColumns(tableName, indexName); + if (sources.length === 0) return null; + // Only the source columns the failing write actually supplied; a partial + // update cannot be re-selected on columns it never mentioned. A NULL-safe + // key part (commit df1c75c4b) is exempt: the enforced key COALESCEs an absent or + // NULL value into the '__global__' bucket, so its key part is knowable + // without the write mentioning the column. + if ( + !sources.every( + (s) => s.nullSafe || Object.prototype.hasOwnProperty.call(values, s.column), + ) + ) { + return null; + } + let existing = 0; + try { + // Re-select by the key the index ENFORCES, not by raw equality (commit df1c75c4b): + // a NULL-safe part compares through the same COALESCE the generation + // expression carries (NULL and absent both land in the global bucket), + // and plain parts use `<=>` — MySQL's NULL-safe equality; this whole + // route is inside `isMysql`. A bare `.where({col: null})` would compare + // `= NULL`, match nothing, and misreport a genuine NULL-organization + // duplicate as a HASH COLLISION. + let q = this.knex(tableName); + for (const s of sources) { + q = s.nullSafe + ? q.whereRaw(`COALESCE(??, '${GLOBAL_TENANT}') = COALESCE(?, '${GLOBAL_TENANT}')`, [ + s.column, + (values[s.column] ?? null) as any, + ]) + : q.whereRaw('?? <=> ?', [s.column, (values[s.column] ?? null) as any]); + } + const rows = await q.limit(1); + existing = rows.length; + } catch { + return null; + } + // Describe the key parts as enforced — NULL-safe parts in their COALESCE + // spelling — so the message names the actual constraint (commit df1c75c4b). + const described = sources + .map((s) => (s.nullSafe ? `COALESCE(${s.column}, '${GLOBAL_TENANT}')` : s.column)) + .join(', '); + if (existing > 0) { + // The ordinary case: a real duplicate. Say so in the declared terms + // rather than leaving MySQL's binary digest as the only explanation. + return ( + `[sql-driver] duplicate value for the UNIQUE constraint '${indexName}' on "${tableName}" ` + + `(${described}). The constraint is physically carried by a hash-shadow column, so the ` + + `server's own message quotes a binary digest instead of the value.` + ); + } + return ( + `[sql-driver] HASH COLLISION on the shadow-carried UNIQUE index '${indexName}' on "${tableName}" ` + + `(${described}): the write was rejected as a duplicate, but NO existing row carries these ` + + `values. Uniqueness on this index is enforced over a SHA-256 of them, so two different ` + + `values produced the same digest. This is expected at a rate near 10^-59 for a billion rows — if ` + + `you are reading this, please report it with the values above; the write itself is legitimate and ` + + `is being refused.` + ); + } + /** The body of `SqlDriver#decorateHashShadowDuplicate`; the method's docblock there is its contract. */ static async decorateHashShadowDuplicate(this: Pick, tableName: string, values: Record, error: unknown): Promise { let explanation: string | null = null; diff --git a/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts index b880dfa3117..0a2bc671a1e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts @@ -2,10 +2,11 @@ /** * [#29520 codemod] The "Database helpers" section of `SqlDriver`: the bodies of - * `sqliteFilename`, `setFileColumnsMovedResolver`, - * `setTenantKeyCertifiedResolver`, `isTenantKeyCertified`, - * `resolveFileColumnsMoved`, `mediaColumnIsJson`, `isJsonField` moved out of - * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * `sqliteFilename`, `ensureDatabaseExists`, `createDatabase`, + * `setFileColumnsMovedResolver`, `setTenantKeyCertifiedResolver`, + * `isTenantKeyCertified`, `resolveFileColumnsMoved`, `mediaColumnIsJson`, + * `isJsonField` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. * * A MOVE, not a redesign. {@link DatabaseHelpersFacet} is a stateless facet of `SqlDriver` * (ruling 6624345766): each static method of it is the body of the `SqlDriver` @@ -26,6 +27,10 @@ * class, and a field a body assigns goes with a SETTER that assigns it on the * driver. * + * `SqlDriver` here is `SqlDriverStatics` (imported AS `SqlDriver`): forwarders to the + * statics the bodies read, private ones included, which the class fills in a + * static block; the class as a type is `SqlDriverClass`. + * * Values that stayed in `sql-driver.ts` are imported from it (lever (a) of * ruling 6591753301); every use is inside a function body, so the import * cycle is never read at evaluation time. `index.ts` re-exports nothing from @@ -33,9 +38,11 @@ */ import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data'; +import { SqliteDatabaseFileError, makeDatabaseDirectory } from './sqlite-database-file.js'; +import knex from 'knex'; import { PRE_DDL_QUESTION_SCOPE, isMultiValuedColumn } from './sql-driver-top-level.js'; -import { JSON_COLUMN_TYPES } from './sql-driver.js'; -import type { SqlDriver } from './sql-driver.js'; +import { JSON_COLUMN_TYPES, SqlDriverStatics as SqlDriver } from './sql-driver.js'; +import type { SqlDriver as SqlDriverClass } from './sql-driver.js'; /** * The `SqlDriver` members the bodies of {@link DatabaseHelpersFacet} read — every one of @@ -45,18 +52,23 @@ import type { SqlDriver } from './sql-driver.js'; * assigns. */ export interface DatabaseHelpersHost { - readonly config: SqlDriver['config']; - fileColumnsMoved: SqlDriver['fileColumnsMoved']; - fileColumnsMovedAsked: SqlDriver['fileColumnsMovedAsked']; - readonly fileColumnsMovedDeclared: SqlDriver['fileColumnsMovedDeclared']; - fileColumnsMovedResolver: SqlDriver['fileColumnsMovedResolver']; - readonly isSqlite: SqlDriver['isSqlite']; - readonly logger: SqlDriver['logger']; - mediaColumnIsJson: SqlDriver['mediaColumnIsJson']; - readonly openedEmptyInMemory: SqlDriver['openedEmptyInMemory']; - tenantKeyCertified: SqlDriver['tenantKeyCertified']; - tenantKeyCertifiedReadFailureReported: SqlDriver['tenantKeyCertifiedReadFailureReported']; - tenantKeyCertifiedResolver: SqlDriver['tenantKeyCertifiedResolver']; + readonly config: SqlDriverClass['config']; + createDatabase: SqlDriverClass['createDatabase']; + fileColumnsMoved: SqlDriverClass['fileColumnsMoved']; + fileColumnsMovedAsked: SqlDriverClass['fileColumnsMovedAsked']; + readonly fileColumnsMovedDeclared: SqlDriverClass['fileColumnsMovedDeclared']; + fileColumnsMovedResolver: SqlDriverClass['fileColumnsMovedResolver']; + readonly isMysql: SqlDriverClass['isMysql']; + readonly isPostgres: SqlDriverClass['isPostgres']; + readonly isSqlite: SqlDriverClass['isSqlite']; + readonly knex: SqlDriverClass['knex']; + readonly logger: SqlDriverClass['logger']; + mediaColumnIsJson: SqlDriverClass['mediaColumnIsJson']; + readonly openedEmptyInMemory: SqlDriverClass['openedEmptyInMemory']; + sqliteFilename: SqlDriverClass['sqliteFilename']; + tenantKeyCertified: SqlDriverClass['tenantKeyCertified']; + tenantKeyCertifiedReadFailureReported: SqlDriverClass['tenantKeyCertifiedReadFailureReported']; + tenantKeyCertifiedResolver: SqlDriverClass['tenantKeyCertifiedResolver']; } /** The bodies of the "Database helpers" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ @@ -78,6 +90,97 @@ export class DatabaseHelpersFacet { return filename.startsWith(':') ? null : filename; } + /** The body of `SqlDriver#ensureDatabaseExists`; the method's docblock there is its contract. */ + static async ensureDatabaseExists(this: Pick) { + // SQLite auto-creates database files but NOT parent directories. + // Ensure the directory exists so better-sqlite3 can create the file — with + // a bounded walk, never Node's recursive mkdir, which loops forever on a + // path like `/proc/x/` (#23696, see `makeDatabaseDirectory`). + if (this.isSqlite) { + const filename = this.sqliteFilename(); + if (filename) { + const { dirname } = await import('node:path'); + const dir = dirname(filename); + if (dir && dir !== '.') { + try { + await makeDatabaseDirectory(dir); + } catch (e) { + throw new SqliteDatabaseFileError(filename, `cannot be created: its directory '${dir}' could not be made`, e); + } + } + } + return; + } + + // Only PostgreSQL and MySQL support programmatic database creation + if (!this.isPostgres && !this.isMysql) return; + + try { + await this.knex.raw('SELECT 1'); + } catch (e: any) { + // PostgreSQL: '3D000' = database does not exist + // MySQL: 'ER_BAD_DB_ERROR' (errno 1049) = unknown database + if ( + e.code === '3D000' || + e.code === 'ER_BAD_DB_ERROR' || + e.errno === 1049 + ) { + await this.createDatabase(); + } else { + throw e; + } + } + } + + /** The body of `SqlDriver#createDatabase`; the method's docblock there is its contract. */ + static async createDatabase(this: Pick) { + const config = this.config as any; + const connection = config.connection; + let dbName = ''; + const adminConfig = { ...config }; + + if (this.isPostgres) { + // PostgreSQL: connect to the 'postgres' maintenance database + if (typeof connection === 'string') { + const url = new URL(connection); + dbName = url.pathname.slice(1); + url.pathname = '/postgres'; + adminConfig.connection = url.toString(); + } else { + dbName = connection.database; + adminConfig.connection = { ...connection, database: 'postgres' }; + } + } else if (this.isMysql) { + // MySQL: connect without specifying a database + if (typeof connection === 'string') { + const url = new URL(connection); + dbName = url.pathname.slice(1); + url.pathname = '/'; + adminConfig.connection = url.toString(); + } else { + dbName = connection.database; + const { database: _db, ...rest } = connection; + adminConfig.connection = rest; + } + } else { + return; // Unsupported dialect for auto-creation + } + + // Same connect bound as the main pool (#3769) — this admin connection is + // opened during boot against the very server we already suspect might be + // unreachable, so it must not be the one place that waits 30s. + const adminKnex = knex(SqlDriver.withConnectBound(adminConfig)); + try { + if (this.isPostgres) { + await adminKnex.raw(`CREATE DATABASE "${dbName}"`); + } else if (this.isMysql) { + await adminKnex.raw(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``); + } + } finally { + await adminKnex.destroy(); + } + } + /** The body of `SqlDriver#setFileColumnsMovedResolver`; the method's docblock there is its contract. */ static setFileColumnsMovedResolver(this: Pick, resolve: () => boolean | Promise): boolean { if (this.fileColumnsMovedDeclared) return false; diff --git a/packages/drivers/driver-sql/src/sql-driver-facet-hosts.ts b/packages/drivers/driver-sql/src/sql-driver-facet-hosts.ts index 61692099826..d18a6e82b4b 100644 --- a/packages/drivers/driver-sql/src/sql-driver-facet-hosts.ts +++ b/packages/drivers/driver-sql/src/sql-driver-facet-hosts.ts @@ -32,6 +32,7 @@ import type { LifecycleHost } from './sql-driver-lifecycle.js'; import type { ManagedDriftHost } from './sql-driver-managed-drift.js'; import type { QueryPlanHost } from './sql-driver-query-plan.js'; import type { RotationHost } from './sql-driver-rotation.js'; +import type { SchemaIntrospectionHost } from './sql-driver-schema-introspection.js'; import type { SchemaSyncSectionHost } from './sql-driver-schema-sync-section.js'; import type { SequenceSemanticsHost } from './sql-driver-sequence-semantics.js'; import type { SqliteSerialisationHost } from './sql-driver-sqlite-serialisation.js'; @@ -80,6 +81,7 @@ export const lifecycleHost = new FacetHost('LifecycleFacet'); export const managedDriftHost = new FacetHost('ManagedDriftFacet'); export const queryPlanHost = new FacetHost('QueryPlanFacet'); export const rotationHost = new FacetHost('RotationFacet'); +export const schemaIntrospectionHost = new FacetHost('SchemaIntrospectionFacet'); export const schemaSyncSectionHost = new FacetHost('SchemaSyncSectionFacet'); export const sequenceSemanticsHost = new FacetHost('SequenceSemanticsFacet'); export const sqliteSerialisationHost = new FacetHost('SqliteSerialisationFacet'); 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 7af700ee3b7..aaab7a9506d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts @@ -10,10 +10,10 @@ * `sqliteTemporalArg`, `filterColumnExpr`, `temporalFilterValue`, * `temporalFilterColumnSql`, `isJsonColumn`, `jsonColumnFieldClass`, * `isNonTextColumn`, `applyTextOperatorOverNonTextColumn`, - * `declaredValueShape`, `logWithheldFilterDiagnostic`, `withWithheldFilterLog`, - * `compileFilters`, `applyContainsLike`, `declaredFieldsFor`, - * `applyFalseConstant` moved out of `sql-driver.ts` whole by - * `scripts/codemod-split-sql-driver.mjs`. + * `declaredValueShape`, `assertOperatorAppliesToColumn`, `applyFilters`, + * `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` @@ -39,15 +39,15 @@ */ import type { DriverOptions, ValueShapeFieldDef } from '@objectstack/spec/data'; -import { presentAsNumber, temporalStorageForm } from '@objectstack/core'; +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, mysqlDatetimeLiteral, normalizeSqliteDatetimeOutput, - presentAuditTimestampOutput, refusalSubtree, withheldFilterDiagnosticOf, + filterArrayReachedDriverError, isEmptyFieldConstraint, jsonColumnOperatorError, mysqlDatetimeLiteral, + normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, refusalSubtree, withheldFilterDiagnosticOf, } from './sql-driver-top-level.js'; import type { ReadPresentationKind } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; @@ -70,12 +70,14 @@ export interface FilterHelpersHost { readonly canonicalTimeFields: SqlDriver['canonicalTimeFields']; coerceFilterValue: SqlDriver['coerceFilterValue']; coercionKey: SqlDriver['coercionKey']; + compileFilters: SqlDriver['compileFilters']; readonly dateFields: SqlDriver['dateFields']; readonly datetimeFields: SqlDriver['datetimeFields']; filterColumnExpr: SqlDriver['filterColumnExpr']; isJsonColumn: SqlDriver['isJsonColumn']; readonly isMysql: SqlDriver['isMysql']; readonly isSqlite: SqlDriver['isSqlite']; + jsonColumnFieldClass: SqlDriver['jsonColumnFieldClass']; readonly jsonFields: SqlDriver['jsonFields']; readonly logger: SqlDriver['logger']; readonly managedObjectFields: SqlDriver['managedObjectFields']; @@ -85,6 +87,8 @@ export interface FilterHelpersHost { readonly numericFields: SqlDriver['numericFields']; readonly objectByPhysicalTable: SqlDriver['objectByPhysicalTable']; presentReadValue: SqlDriver['presentReadValue']; + readonly readableIdsDriverName: SqlDriver['readableIdsDriverName']; + readonly readableIdsRefusalLog: SqlDriver['readableIdsRefusalLog']; remoteColumn: SqlDriver['remoteColumn']; resolveWithheldFilterRefusal: SqlDriver['resolveWithheldFilterRefusal']; sqliteCanonicalDatetimeSql: SqlDriver['sqliteCanonicalDatetimeSql']; @@ -389,6 +393,43 @@ export class FilterHelpersFacet { return shapes && Object.prototype.hasOwnProperty.call(shapes, localField) ? shapes[localField] : undefined; } + /** The body of `SqlDriver#assertOperatorAppliesToColumn`; the method's docblock there is its contract. */ + static assertOperatorAppliesToColumn(this: Pick, + table: string | null | undefined, + localField: string, + column: string, + op: string, + bare = false, + // [#8197] The filter node this constraint was read from, so the refusal can + // be resolved against the query's `where` root like every other withheld + // one. Optional, and absent means WITHHELD — a subclass that overrides this + // method without threading it degrades onto the fail-closed branch rather + // than onto the disclosing one. + subtree?: unknown, + ): void { + if (!JSON_COLUMN_INCOMPATIBLE_OPERATORS.has(op)) return; + // [#21236] The same JSON-column question {@link isJsonColumn} answers, with + // the column's class beside it, so the refusal names the class's repair. + const fieldClass = this.jsonColumnFieldClass(table, localField); + if (fieldClass === undefined) return; + throw jsonColumnOperatorError(column, op, bare, fieldClass, subtree); + } + + /** The body of `SqlDriver#applyFilters`; the method's docblock there is its contract. */ + static applyFilters(this: Pick, builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { + // [ADR-0139 D3] A `ReadableIds` leaf this compiler will not evaluate — an + // UNCOMPOSED one anywhere, a composed one outside a `$in` comparand — is + // refused before anything compiles: INTERNAL_ERROR / 500, logged at + // `error` once per driver. ⛔ Never evaluated as "no restriction". + const misplaced = findMisplacedReadableIds(filters, true); + if (misplaced) throw refuseReadableIdsOnce(this.readableIdsDriverName, misplaced, this.logger, this.readableIdsRefusalLog); + try { + this.compileFilters(builder, filters, options); + } catch (err) { + throw this.resolveWithheldFilterRefusal(err, filters); + } + } + /** 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-internal-helpers.ts b/packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts index e47052cc0c3..682f5e7efc3 100644 --- a/packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts @@ -2,9 +2,10 @@ /** * [#29520 codemod] The "Internal helpers" section of `SqlDriver`: the bodies of - * `getKnex`, `resolveTenantField`, `isMultiTenantMode`, `applyTenantScope`, - * `readableIdsSubquery`, `injectTenantOnInsert`, `auditMissingTenant` moved out - * of `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * `getKnex`, `getBuilder`, `resolveTenantField`, `isMultiTenantMode`, + * `applyTenantScope`, `readableIdsSubquery`, `injectTenantOnInsert`, + * `auditMissingTenant` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. * * A MOVE, not a redesign. {@link InternalHelpersFacet} is a stateless facet of `SqlDriver` * (ruling 6624345766): each static method of it is the body of the `SqlDriver` @@ -50,6 +51,8 @@ export interface InternalHelpersHost { isMultiTenantMode: SqlDriver['isMultiTenantMode']; readonly knex: SqlDriver['knex']; readonly logger: SqlDriver['logger']; + readonly physicalSchemaByObject: SqlDriver['physicalSchemaByObject']; + readonly physicalTableByObject: SqlDriver['physicalTableByObject']; remoteColumn: SqlDriver['remoteColumn']; resolveTenantField: SqlDriver['resolveTenantField']; readonly tenantAuditWarned: SqlDriver['tenantAuditWarned']; @@ -63,6 +66,25 @@ export class InternalHelpersFacet { return this.knex; } + /** The body of `SqlDriver#getBuilder`; the method's docblock there is its contract. */ + static getBuilder(this: Pick, object: string, options?: DriverOptions) { + // Federation (ADR-0015): an external object resolves to its remote table + // (`external.remoteName`, optionally schema-qualified). Managed objects miss + // both maps, so this is `this.knex(object)` — unchanged. `.withSchema()` is + // applied on the builder (not via `knex.withSchema().from()`) so the builder + // type is identical to the managed path for every downstream caller. + const physical = this.physicalTableByObject[object] ?? object; + let builder = this.knex(physical); + const remoteSchema = this.physicalSchemaByObject[object]; + if (remoteSchema) { + builder = builder.withSchema(remoteSchema); + } + if (options?.transaction) { + builder = builder.transacting(options.transaction as Knex.Transaction); + } + return builder; + } + /** The body of `SqlDriver#resolveTenantField`; the method's docblock there is its contract. */ static resolveTenantField(this: Pick, object: string): string | null { const tableName = StorageNameMapping.resolveTableName({ name: object } as any); diff --git a/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts b/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts index 7af40a67684..5fc2f7a4d5f 100644 --- a/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts +++ b/packages/drivers/driver-sql/src/sql-driver-lifecycle.ts @@ -3,10 +3,10 @@ /** * [#29520 codemod] The "Lifecycle" section of `SqlDriver`: the bodies of * `connect`, `releaseRefusedSqliteConnection`, `applyAutoVacuumIncremental`, - * `autoVacuumOf`, `resolveSqliteJournalMode`, `applySqliteJournalMode`, - * `verifyWalReadable`, `isSqliteBusyError`, `journalModeOf`, `checkHealth`, - * `disconnect` moved out of `sql-driver.ts` whole by - * `scripts/codemod-split-sql-driver.mjs`. + * `autoVacuumOf`, `resolveSqliteJournalMode`, `supportsWalJournal`, + * `applySqliteJournalMode`, `verifyWalReadable`, `isSqliteBusyError`, + * `journalModeOf`, `checkHealth`, `disconnect` moved out of `sql-driver.ts` + * whole by `scripts/codemod-split-sql-driver.mjs`. * * A MOVE, not a redesign. {@link LifecycleFacet} is a stateless facet of `SqlDriver` * (ruling 6624345766): each static method of it is the body of the `SqlDriver` @@ -267,6 +267,11 @@ export class LifecycleFacet { return 'wal'; } + /** The body of `SqlDriver#supportsWalJournal`; the method's docblock there is its contract. */ + static supportsWalJournal(): boolean { + return true; + } + /** The body of `SqlDriver#applySqliteJournalMode`; the method's docblock there is its contract. */ static async applySqliteJournalMode(this: Pick): Promise { if (!this.supportsWalJournal) return; diff --git a/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts b/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts index ebe8a6c4bc5..d8acaf153e9 100644 --- a/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts +++ b/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts @@ -2,7 +2,7 @@ /** * [#29520 codemod] The "Managed-schema drift & reconcile (#2186)" section of - * `SqlDriver`: the bodies of `isProductionEnv`, + * `SqlDriver`: the bodies of `dialectName`, `isProductionEnv`, * `probeNullSafeUniqueDuplicates`, `buildRebuiltColumn`, `buildIndexName`, * `getExistingIndexNames`, `uniqueIndexesFromFields`, `dropIndexIfExists`, * `syncTableIndexes` moved out of `sql-driver.ts` whole by @@ -32,6 +32,7 @@ */ import { GLOBAL_TENANT, buildIndexName, uniqueIndexesFromFields } from './schema-drift.js'; +import type { SqlDialectName } from './schema-drift.js'; import type { Knex } from 'knex'; import { uniqueProbeNotNullClause } from './sql-driver-top-level.js'; import type { IntrospectedColumn } from './sql-driver-top-level.js'; @@ -48,6 +49,7 @@ export interface ManagedDriftHost { introspectIndexes: SqlDriver['introspectIndexes']; readonly isMysql: SqlDriver['isMysql']; readonly isPostgres: SqlDriver['isPostgres']; + readonly isSqlite: SqlDriver['isSqlite']; readonly knex: SqlDriver['knex']; syncDeclaredIndexes: SqlDriver['syncDeclaredIndexes']; uniqueIndexesFromFields: SqlDriver['uniqueIndexesFromFields']; @@ -55,6 +57,14 @@ export interface ManagedDriftHost { /** The bodies of the "Managed-schema drift & reconcile (#2186)" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ export class ManagedDriftFacet { + /** The body of `SqlDriver#dialectName`; the method's docblock there is its contract. */ + static dialectName(this: Pick): SqlDialectName { + if (this.isSqlite) return 'sqlite'; + if (this.isPostgres) return 'postgres'; + if (this.isMysql) return 'mysql'; + return 'unknown'; + } + /** The body of `SqlDriver#isProductionEnv`; the method's docblock there is its contract. */ static isProductionEnv(): boolean { try { diff --git a/packages/drivers/driver-sql/src/sql-driver-schema-introspection.ts b/packages/drivers/driver-sql/src/sql-driver-schema-introspection.ts new file mode 100644 index 00000000000..a8833c5d55b --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-schema-introspection.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Schema Introspection" section of `SqlDriver`: the + * bodies of `listPhysicalColumns` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. {@link SchemaIntrospectionFacet} is a stateless facet of `SqlDriver` + * (ruling 6624345766): each static method of it is the body of the `SqlDriver` + * method of the same name exactly as it stood: not re-indented, not + * formatted, not edited. The method stays on the class as a one-call stub with + * its signature, so the published surface, an instance spy and a subclass + * override are what they were. A public or protected method keeps its + * docblock on the class, the contract; a private method's docblock moved here + * with its body. The state stays on `SqlDriver`. + * + * The bodies reach the class through {@link SchemaIntrospectionHost}: exactly + * the `SqlDriver` members they read, private ones included. Every stub hands + * its body, by a `.call`, the same host: `schemaIntrospectionHost` in + * `sql-driver-facet-hosts.ts`, built once per driver by the class's static + * block. Methods go as closures that call them on the driver at call time, so a + * subclass override or an instance spy is still the one reached; fields and + * accessors go as GETTERS, read when the body reads them, as they were on the + * class, and a field a body assigns goes with a SETTER that assigns it on the + * driver. + * + * The module imports `sql-driver.ts` for types only, so the two modules form no + * evaluation cycle. `index.ts` re-exports nothing from this module. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import type { Knex } from 'knex'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the bodies of {@link SchemaIntrospectionFacet} read — every one of + * them, and no other. Each is typed AS the class member (an indexed access on + * the class), so a change to a member's signature on the class is a change + * here too. `readonly` members are the getters; the others are fields a body + * assigns. + */ +export interface SchemaIntrospectionHost { + readonly knex: SqlDriver['knex']; + readonly physicalSchemaByObject: SqlDriver['physicalSchemaByObject']; + readonly physicalTableByObject: SqlDriver['physicalTableByObject']; +} + +/** The bodies of the "Schema Introspection" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class SchemaIntrospectionFacet { + /** The body of `SqlDriver#listPhysicalColumns`; the method's docblock there is its contract. */ + static async listPhysicalColumns(this: Pick, object: string, options?: DriverOptions): Promise { + let catalog = this.knex(this.physicalTableByObject[object] ?? object); + const remoteSchema = this.physicalSchemaByObject[object]; + if (remoteSchema) catalog = catalog.withSchema(remoteSchema); + if (options?.transaction) catalog = catalog.transacting(options.transaction as Knex.Transaction); + const info = (await catalog.columnInfo()) as Record; + return Object.keys(info ?? {}); + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 6f6a4d5b8bd..25ddd703881 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -231,10 +231,10 @@ 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, jsonColumnOperatorError, - nowAnchorOf, nowAnchorPositionError, reduceFilterKey, reduceFilterNode, refusalSubtree, - refuseRejectedReferenceAlias, refuseUndeclaredFieldType, retiredFilterOperatorError, unsupportedFilterError, - unsupportedFilterOperatorError, withheldFilterDiagnosticOf, + isDeferredDdlLockWaitRefusal, isFilterNode, isMultiValuedColumn, isNowDefaultValue, nowAnchorOf, + nowAnchorPositionError, reduceFilterKey, reduceFilterNode, refusalSubtree, refuseRejectedReferenceAlias, + refuseUndeclaredFieldType, retiredFilterOperatorError, unsupportedFilterError, unsupportedFilterOperatorError, + withheldFilterDiagnosticOf, } from './sql-driver-top-level.js'; import type { AutoNumberReservation, ConflictTargetVerdict, IntrospectedColumn, IntrospectedForeignKey, IntrospectedSchema, @@ -283,6 +283,8 @@ import { RotationFacet } from './sql-driver-rotation.js'; import { DeferredDdlFacet } from './sql-driver-deferred-ddl.js'; // [#29520 codemod] Managed-schema drift & reconcile (#2186): the methods of the same names below hand the bodies what they read (`ManagedDriftHost`). import { ManagedDriftFacet } from './sql-driver-managed-drift.js'; +// [#29520 codemod] Schema Introspection: the methods of the same names below hand the bodies what they read (`SchemaIntrospectionHost`). +import { SchemaIntrospectionFacet } from './sql-driver-schema-introspection.js'; // [#29520 codemod] Internal helpers: the methods of the same names below hand the bodies what they read (`InternalHelpersHost`). import { InternalHelpersFacet } from './sql-driver-internal-helpers.js'; // [#29520 codemod] Filter helpers: the methods of the same names below hand the bodies what they read (`FilterHelpersHost`). @@ -301,8 +303,9 @@ import { SqliteSerialisationFacet } from './sql-driver-sqlite-serialisation.js'; import { aggregationHost, bulkOperationsHost, classHeadHost, columnCreationHost, crudHost, databaseHelpersHost, deferredDdlHost, distinctHost, fieldMappingHost, filterHelpersHost, internalHelpersHost, lifecycleHost, - managedDriftHost, queryPlanHost, rotationHost, schemaSyncSectionHost, sequenceSemanticsHost, - sqliteSerialisationHost, transactionsSectionHost, windowFunctionBuilderHost, windowFunctionsHost, + managedDriftHost, queryPlanHost, rotationHost, schemaIntrospectionHost, schemaSyncSectionHost, + sequenceSemanticsHost, sqliteSerialisationHost, transactionsSectionHost, windowFunctionBuilderHost, + windowFunctionsHost, } from './sql-driver-facet-hosts.js'; // GLOBAL_TENANT ('__global__') — the sentinel for the NULL-organization @@ -400,6 +403,7 @@ export interface SqlDriverStaticsHost { readonly isSqliteBusyError: (typeof SqlDriver)['isSqliteBusyError']; readonly journalModeOf: (typeof SqlDriver)['journalModeOf']; readonly varcharPackLength: (typeof SqlDriver)['varcharPackLength']; + readonly withConnectBound: (typeof SqlDriver)['withConnectBound']; readonly withMysqlCalendarDayAsText: (typeof SqlDriver)['withMysqlCalendarDayAsText']; readonly withPostgresCalendarDayAsText: (typeof SqlDriver)['withPostgresCalendarDayAsText']; readonly withUtcSession: (typeof SqlDriver)['withUtcSession']; @@ -805,10 +809,7 @@ export class SqlDriver implements IDataDriver { * to `warn` when the injected sink has no `error`. */ error?: (msg: string, meta?: any) => void; - } = { - warn: (msg, meta) => console.warn(msg, meta ?? ''), - error: (msg, meta) => console.error(msg, meta ?? ''), - }; + } = ClassHeadFacet.logger(); /** * [#9609] Emit on the durability-degradation channel: `error` when the @@ -1094,7 +1095,8 @@ export class SqlDriver implements IDataDriver { * statement (per `create`, and per `bulkCreate` batch). */ protected get insertReturnsStoredRows(): boolean { - return this.isSqlite || this.isPostgres; + // [#29520 codemod] The body moved to `sql-driver-class-head.ts` (see `ClassHeadHost`). + return ClassHeadFacet.insertReturnsStoredRows.call(classHeadHost.of(this)); } /** @@ -1167,23 +1169,8 @@ export class SqlDriver implements IDataDriver { } protected get dateGranularityCapabilities(): Record { - if (this.isPostgres) { - return { day: true, month: true, quarter: true, year: true, week: true }; - } - if (this.isMysql) { - return { day: true, month: true, quarter: true, year: true, week: true }; - } - if (this.isSqlite) { - // [#21595] All five, `week` included. The ISO week is computed by the - // Thursday rule through date modifiers (the `week` arm of - // `buildDateBucketExpr`), never `strftime('%V')` / `'%G'`: those arrived - // in SQLite 3.46, and libSQL (`driver-turso`) bundles an older engine. - // Measured: better-sqlite3 13.0.3 runs 3.53.4, `@libsql/client` 0.18.0 - // runs 3.45.1 (where `%V` answers NULL), sql.js 1.14.2 - // (`driver-sqlite-wasm`) runs 3.49.1. The expression runs on all three. - return { day: true, month: true, quarter: true, year: true, week: true }; - } - return {}; + // [#29520 codemod] The body moved to `sql-driver-class-head.ts` (see `ClassHeadHost`). + return ClassHeadFacet.dateGranularityCapabilities.call(classHeadHost.of(this)); } /** @@ -1684,7 +1671,8 @@ export class SqlDriver implements IDataDriver { * cross-process concurrency buys it nothing. */ protected get supportsWalJournal(): boolean { - return true; + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts`. + return LifecycleFacet.supportsWalJournal(); } /** @@ -4083,7 +4071,7 @@ export class SqlDriver implements IDataDriver { */ rotateShards( objectDef: { name: string; fields?: Record; tenancy?: any; indexes?: any[]; lifecycle?: any }, - nowMs: number = Date.now(), + nowMs?: number, ): Promise<{ object: string; current: string; shards: string[]; dropped: string[] }> { // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). return RotationFacet.rotateShards.call(rotationHost.of(this), objectDef, nowMs); @@ -4093,7 +4081,7 @@ export class SqlDriver implements IDataDriver { tableName: string, obj: { name: string; fields?: Record; tenancy?: any; indexes?: any[] }, policy: { shards: number; unit: 'day' | 'week' | 'month' }, - nowMs: number = Date.now(), + nowMs?: number, ): Promise<{ object: string; current: string; shards: string[]; dropped: string[] }> { // [#25521 cut 17] The body moved to `sql-driver-schema-sync.ts` (see `SchemaSyncHost`). const readKnex = () => this.knex; @@ -5495,10 +5483,8 @@ export class SqlDriver implements IDataDriver { * answer (a client neither emission set names), not a missing one. */ public get dialectName(): SqlDialectName { - if (this.isSqlite) return 'sqlite'; - if (this.isPostgres) return 'postgres'; - if (this.isMysql) return 'mysql'; - return 'unknown'; + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts` (see `ManagedDriftHost`). + return ManagedDriftFacet.dialectName.call(managedDriftHost.of(this)); } /** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */ @@ -6252,13 +6238,9 @@ export class SqlDriver implements IDataDriver { * carry (`check:tenant-chokepoint`) has nothing to scope. The table is * resolved as `getBuilder` resolves it. */ - async listPhysicalColumns(object: string, options?: DriverOptions): Promise { - let catalog = this.knex(this.physicalTableByObject[object] ?? object); - const remoteSchema = this.physicalSchemaByObject[object]; - if (remoteSchema) catalog = catalog.withSchema(remoteSchema); - if (options?.transaction) catalog = catalog.transacting(options.transaction as Knex.Transaction); - const info = (await catalog.columnInfo()) as Record; - return Object.keys(info ?? {}); + listPhysicalColumns(object: string, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-schema-introspection.ts` (see `SchemaIntrospectionHost`). + return SchemaIntrospectionFacet.listPhysicalColumns.call(schemaIntrospectionHost.of(this), object, options); } introspectSchema(): Promise { @@ -6292,22 +6274,9 @@ export class SqlDriver implements IDataDriver { return InternalHelpersFacet.getKnex.call(internalHelpersHost.of(this)); } - protected getBuilder(object: string, options?: DriverOptions) { - // Federation (ADR-0015): an external object resolves to its remote table - // (`external.remoteName`, optionally schema-qualified). Managed objects miss - // both maps, so this is `this.knex(object)` — unchanged. `.withSchema()` is - // applied on the builder (not via `knex.withSchema().from()`) so the builder - // type is identical to the managed path for every downstream caller. - const physical = this.physicalTableByObject[object] ?? object; - let builder = this.knex(physical); - const remoteSchema = this.physicalSchemaByObject[object]; - if (remoteSchema) { - builder = builder.withSchema(remoteSchema); - } - if (options?.transaction) { - builder = builder.transacting(options.transaction as Knex.Transaction); - } - return builder; + protected getBuilder(object: string, options?: DriverOptions): knex.Knex.QueryBuilder { + // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). + return InternalHelpersFacet.getBuilder.call(internalHelpersHost.of(this), object, options); } /** @@ -7239,12 +7208,8 @@ export class SqlDriver implements IDataDriver { // than onto the disclosing one. subtree?: unknown, ): void { - if (!JSON_COLUMN_INCOMPATIBLE_OPERATORS.has(op)) return; - // [#21236] The same JSON-column question {@link isJsonColumn} answers, with - // the column's class beside it, so the refusal names the class's repair. - const fieldClass = this.jsonColumnFieldClass(table, localField); - if (fieldClass === undefined) return; - throw jsonColumnOperatorError(column, op, bare, fieldClass, subtree); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.assertOperatorAppliesToColumn.call(filterHelpersHost.of(this), table, localField, column, op, bare, subtree); } /** @@ -7279,18 +7244,9 @@ export class SqlDriver implements IDataDriver { * `code` and `status` are the redacted refusal's own, and the diagnostic * stays on the symbol key where nothing that serialises an error can reach it. */ - protected applyFilters(builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { - // [ADR-0139 D3] A `ReadableIds` leaf this compiler will not evaluate — an - // UNCOMPOSED one anywhere, a composed one outside a `$in` comparand — is - // refused before anything compiles: INTERNAL_ERROR / 500, logged at - // `error` once per driver. ⛔ Never evaluated as "no restriction". - const misplaced = findMisplacedReadableIds(filters, true); - if (misplaced) throw refuseReadableIdsOnce(this.readableIdsDriverName, misplaced, this.logger, this.readableIdsRefusalLog); - try { - this.compileFilters(builder, filters, options); - } catch (err) { - throw this.resolveWithheldFilterRefusal(err, filters); - } + protected applyFilters(builder: Knex.QueryBuilder, filters: any, options?: DriverOptions): void { + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.applyFilters.call(filterHelpersHost.of(this), builder, filters, options); } /** @@ -8693,103 +8649,14 @@ export class SqlDriver implements IDataDriver { * is a valid sign-in refused as a duplicate. A refusal the operator can read * is strictly better than a constraint that quietly means something else. */ - protected async explainUnkeyableTextColumn( + protected explainUnkeyableTextColumn( tableName: string, indexName: string, columns: string[], cause: unknown, ): Promise { - const code = (cause as { code?: string } | undefined)?.code; - if (code !== 'ER_BLOB_KEY_WITHOUT_LENGTH' && code !== 'ER_TOO_LONG_KEY') return null; - let offenders: string[] = []; - try { - const info: Record = await this.knex(tableName).columnInfo(); - offenders = columns.filter((c) => /text|blob/i.test(String(info?.[c]?.type ?? ''))); - } catch { - // Introspection is a nicety here; the advice below holds without it. - } - const named = - offenders.length > 0 - ? `Column(s) ${offenders.map((c) => `"${c}"`).join(', ')} are stored as TEXT` - : 'One or more of its key columns is stored as TEXT'; - - // Commit ebcc34e89: the SAME refusal has two causes, and the message below is true of - // only one of them. On CREATE the field really declares no usable bound, so - // "declare `maxLength`" is the fix. On an UPGRADE the bound IS declared — - // the additive sync never rewrites a column's type (#3728), so a column - // created before the declaration stays TEXT forever — and that same sentence - // tells the operator to do what they already did, once per boot, in - // production, which reads as the release they just deployed being broken. - // - // The discriminator is per COLUMN and needs BOTH halves, which is why it is - // computed here rather than inferred from the error code: a bounded - // declaration alone does not imply a stale column, because `ER_TOO_LONG_KEY` - // can refuse a COMPOSITE whose parts are each individually keyable (see - // {@link MAX_KEYABLE_VARCHAR_CHARS} — the bound is per KEY, not per column). - // So the branch fires only where the physical column is TEXT *and* - // {@link keyableTextLength} says a fresh create would have emitted - // `varchar(n)` for it. When `columnInfo()` fails, `offenders` is empty and - // this map stays empty, so the CREATE-path message stands — the honest - // reading, since without introspection the two causes are indistinguishable. - // - // `declaredFieldsFor` returns `undefined` for a table this driver does not - // own the column set of, and misses a SHARD table (registered under the base - // name). Both degrade to the message below rather than guessing. - const declaredFields = this.declaredFieldsFor(tableName); - const staleBounds = new Map(); - for (const c of offenders) { - const field = declaredFields?.[c]; - const bound = field ? this.keyableTextLength(field) : null; - if (bound !== null) staleBounds.set(c, bound); - } - - if (staleBounds.size > 0) { - const staleNamed = [...staleBounds] - .map(([c, n]) => `"${c}" (declares \`maxLength: ${n}\`)`) - .join(', '); - const [firstColumn, firstBound] = [...staleBounds][0]; - // Named in full, deliberately. The mirror case in `schema-drift.ts` (the - // unbounded field over a stale `varchar`) already had to derive this, and - // the two MySQL `MODIFY` hazards it names are recorded twice more in this - // file, on the datetime and time widenings: MODIFY does NOT repeat a - // `NOT NULL`, and it DROPS a `DEFAULT` it does not restate. An operator - // who follows an abbreviated version of this remedy ends up worse off than - // the no-op they started with — a column that lost its default. - const others = - offenders.length > staleBounds.size - ? ` Column(s) ${offenders - .filter((c) => !staleBounds.has(c)) - .map((c) => `"${c}"`) - .join(', ')} in the same key declare no usable bound and DO need \`maxLength\` ` + - `declared on the field.` - : ''; - return ( - `[sql-driver] cannot create index '${indexName}' on "${tableName}" — MySQL refuses a TEXT/BLOB ` + - `column in a key without a key length. Column(s) ${staleNamed} are stored as TEXT even though the ` + - `field ALREADY declares a usable \`maxLength\` — the column was created before that bound was ` + - `declared, and the additive sync never rewrites a column's type, so re-declaring \`maxLength\` ` + - `changes nothing here. REMEDY: convert the column(s) to varchar(n) by hand, with a backup ` + - `taken first, restating the FULL column definition on MySQL (MODIFY drops a NOT NULL or DEFAULT you ` + - `do not repeat) — e.g. ALTER TABLE \`${tableName}\` MODIFY \`${firstColumn}\` ` + - `varchar(${firstBound}) plus that column's existing NOT NULL / DEFAULT clauses, restated verbatim; ` + - `— and let the next boot create this index. ObjectStack does NOT rewrite the column for you: that ` + - `ALTER needs an exclusive metadata lock on the table, which makes it a destructive, hard-to-roll-back ` + - `action and a deliberate manual floor rather than a boot-time one.${others} The table exists but this ` + - `index does NOT, so any uniqueness it declared is currently unenforced. A prefix index is deliberately ` + - `not substituted: on a UNIQUE index it constrains the prefix rather than the value, and rejects two ` + - `different values that share one.` - ); - } - - return ( - `[sql-driver] cannot create index '${indexName}' on "${tableName}" — MySQL refuses a TEXT/BLOB ` + - `column in a key without a key length. ${named} because the field declares no \`maxLength\` (or one ` + - `wider than ${SqlDriver.MAX_KEYABLE_VARCHAR_CHARS} characters, the most a utf8mb4 key part can hold). ` + - `Declare \`maxLength\` on the field(s) so the column is emitted as varchar(n) and can be keyed. ` + - `The table exists but this index does NOT, so any uniqueness it declared is currently ` + - `unenforced. A prefix index is deliberately not substituted: on a UNIQUE index it constrains the ` + - `prefix rather than the value, and rejects two different values that share one.` - ); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.explainUnkeyableTextColumn.call(columnCreationHost.of(this), tableName, indexName, columns, cause); } /** @@ -9024,86 +8891,13 @@ export class SqlDriver implements IDataDriver { * Returns `null` when this failure is not a shadow-index conflict at all, so * the caller's existing handling is unchanged. */ - protected async explainHashShadowDuplicate( + protected explainHashShadowDuplicate( tableName: string, values: Record, cause: unknown, ): Promise { - if (!this.isMysql) return null; - if ((cause as { code?: string } | undefined)?.code !== 'ER_DUP_ENTRY') return null; - const message = String((cause as { message?: string } | undefined)?.message ?? ''); - // MySQL names the key as `table.index` (8.0) or `index` (5.7) — take the - // last path segment either way. - const keyed = /for key '([^']+)'/.exec(message); - if (!keyed) return null; - const indexName = keyed[1]!.split('.').pop()!; - const shadow = SqlDriver.hashShadowColumnFor(indexName); - let shadowExists = false; - try { - shadowExists = await this.knex.schema.hasColumn(tableName, shadow); - } catch { - return null; - } - if (!shadowExists) return null; - const sources = await this.hashShadowSourceColumns(tableName, indexName); - if (sources.length === 0) return null; - // Only the source columns the failing write actually supplied; a partial - // update cannot be re-selected on columns it never mentioned. A NULL-safe - // key part (commit df1c75c4b) is exempt: the enforced key COALESCEs an absent or - // NULL value into the '__global__' bucket, so its key part is knowable - // without the write mentioning the column. - if ( - !sources.every( - (s) => s.nullSafe || Object.prototype.hasOwnProperty.call(values, s.column), - ) - ) { - return null; - } - let existing = 0; - try { - // Re-select by the key the index ENFORCES, not by raw equality (commit df1c75c4b): - // a NULL-safe part compares through the same COALESCE the generation - // expression carries (NULL and absent both land in the global bucket), - // and plain parts use `<=>` — MySQL's NULL-safe equality; this whole - // route is inside `isMysql`. A bare `.where({col: null})` would compare - // `= NULL`, match nothing, and misreport a genuine NULL-organization - // duplicate as a HASH COLLISION. - let q = this.knex(tableName); - for (const s of sources) { - q = s.nullSafe - ? q.whereRaw(`COALESCE(??, '${GLOBAL_TENANT}') = COALESCE(?, '${GLOBAL_TENANT}')`, [ - s.column, - (values[s.column] ?? null) as any, - ]) - : q.whereRaw('?? <=> ?', [s.column, (values[s.column] ?? null) as any]); - } - const rows = await q.limit(1); - existing = rows.length; - } catch { - return null; - } - // Describe the key parts as enforced — NULL-safe parts in their COALESCE - // spelling — so the message names the actual constraint (commit df1c75c4b). - const described = sources - .map((s) => (s.nullSafe ? `COALESCE(${s.column}, '${GLOBAL_TENANT}')` : s.column)) - .join(', '); - if (existing > 0) { - // The ordinary case: a real duplicate. Say so in the declared terms - // rather than leaving MySQL's binary digest as the only explanation. - return ( - `[sql-driver] duplicate value for the UNIQUE constraint '${indexName}' on "${tableName}" ` + - `(${described}). The constraint is physically carried by a hash-shadow column, so the ` + - `server's own message quotes a binary digest instead of the value.` - ); - } - return ( - `[sql-driver] HASH COLLISION on the shadow-carried UNIQUE index '${indexName}' on "${tableName}" ` + - `(${described}): the write was rejected as a duplicate, but NO existing row carries these ` + - `values. Uniqueness on this index is enforced over a SHA-256 of them, so two different ` + - `values produced the same digest. This is expected at a rate near 10^-59 for a billion rows — if ` + - `you are reading this, please report it with the values above; the write itself is legitimate and ` + - `is being refused.` - ); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.explainHashShadowDuplicate.call(columnCreationHost.of(this), tableName, values, cause); } /** @@ -10047,93 +9841,14 @@ export class SqlDriver implements IDataDriver { return DatabaseHelpersFacet.sqliteFilename.call(databaseHelpersHost.of(this)); } - protected async ensureDatabaseExists() { - // SQLite auto-creates database files but NOT parent directories. - // Ensure the directory exists so better-sqlite3 can create the file — with - // a bounded walk, never Node's recursive mkdir, which loops forever on a - // path like `/proc/x/` (#23696, see `makeDatabaseDirectory`). - if (this.isSqlite) { - const filename = this.sqliteFilename(); - if (filename) { - const { dirname } = await import('node:path'); - const dir = dirname(filename); - if (dir && dir !== '.') { - try { - await makeDatabaseDirectory(dir); - } catch (e) { - throw new SqliteDatabaseFileError(filename, `cannot be created: its directory '${dir}' could not be made`, e); - } - } - } - return; - } - - // Only PostgreSQL and MySQL support programmatic database creation - if (!this.isPostgres && !this.isMysql) return; - - try { - await this.knex.raw('SELECT 1'); - } catch (e: any) { - // PostgreSQL: '3D000' = database does not exist - // MySQL: 'ER_BAD_DB_ERROR' (errno 1049) = unknown database - if ( - e.code === '3D000' || - e.code === 'ER_BAD_DB_ERROR' || - e.errno === 1049 - ) { - await this.createDatabase(); - } else { - throw e; - } - } + protected ensureDatabaseExists(): Promise { + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + return DatabaseHelpersFacet.ensureDatabaseExists.call(databaseHelpersHost.of(this)); } - protected async createDatabase() { - const config = this.config as any; - const connection = config.connection; - let dbName = ''; - const adminConfig = { ...config }; - - if (this.isPostgres) { - // PostgreSQL: connect to the 'postgres' maintenance database - if (typeof connection === 'string') { - const url = new URL(connection); - dbName = url.pathname.slice(1); - url.pathname = '/postgres'; - adminConfig.connection = url.toString(); - } else { - dbName = connection.database; - adminConfig.connection = { ...connection, database: 'postgres' }; - } - } else if (this.isMysql) { - // MySQL: connect without specifying a database - if (typeof connection === 'string') { - const url = new URL(connection); - dbName = url.pathname.slice(1); - url.pathname = '/'; - adminConfig.connection = url.toString(); - } else { - dbName = connection.database; - const { database: _db, ...rest } = connection; - adminConfig.connection = rest; - } - } else { - return; // Unsupported dialect for auto-creation - } - - // Same connect bound as the main pool (#3769) — this admin connection is - // opened during boot against the very server we already suspect might be - // unreachable, so it must not be the one place that waits 30s. - const adminKnex = knex(SqlDriver.withConnectBound(adminConfig)); - try { - if (this.isPostgres) { - await adminKnex.raw(`CREATE DATABASE "${dbName}"`); - } else if (this.isMysql) { - await adminKnex.raw(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``); - } - } finally { - await adminKnex.destroy(); - } + protected createDatabase(): Promise { + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + return DatabaseHelpersFacet.createDatabase.call(databaseHelpersHost.of(this)); } /** @@ -10525,6 +10240,7 @@ export class SqlDriver implements IDataDriver { isSqliteBusyError: { value: (...args: Parameters) => SqlDriver.isSqliteBusyError(...args) }, journalModeOf: { value: (...args: Parameters) => SqlDriver.journalModeOf(...args) }, varcharPackLength: { value: (...args: Parameters) => SqlDriver.varcharPackLength(...args) }, + withConnectBound: { value: (...args: Parameters) => SqlDriver.withConnectBound(...args) }, withMysqlCalendarDayAsText: { value: (...args: Parameters) => SqlDriver.withMysqlCalendarDayAsText(...args) }, withPostgresCalendarDayAsText: { value: (...args: Parameters) => SqlDriver.withPostgresCalendarDayAsText(...args) }, withUtcSession: { value: (...args: Parameters) => SqlDriver.withUtcSession(...args) }, @@ -10587,6 +10303,8 @@ export class SqlDriver implements IDataDriver { assertDialectEmits: (...a) => driver.assertDialectEmits(...a), buildDateBucketExpr: (...a) => driver.buildDateBucketExpr(...a), get config() { return driver.config; }, + get isMysql() { return driver.isMysql; }, + get isPostgres() { return driver.isPostgres; }, get isSqlite() { return driver.isSqlite; }, get knex() { return driver.knex; }, get openedEmptyInMemory() { return driver.openedEmptyInMemory; }, @@ -10594,11 +10312,14 @@ export class SqlDriver implements IDataDriver { get schemaMode() { return driver.schemaMode; }, })); columnCreationHost.install((driver) => ({ + declaredFieldsFor: (...a) => driver.declaredFieldsFor(...a), explainHashShadowDuplicate: (...a) => driver.explainHashShadowDuplicate(...a), explainRowSizeOverflow: (...a) => driver.explainRowSizeOverflow(...a), hashShadowColumnState: (...a) => driver.hashShadowColumnState(...a), + hashShadowSourceColumns: (...a) => driver.hashShadowSourceColumns(...a), get isMysql() { return driver.isMysql; }, get isSqlite() { return driver.isSqlite; }, + keyableTextLength: (...a) => driver.keyableTextLength(...a), get knex() { return driver.knex; }, logDurabilityFailure: (...a) => driver.logDurabilityFailure(...a), get logger() { return driver.logger; }, @@ -10644,6 +10365,7 @@ export class SqlDriver implements IDataDriver { })); databaseHelpersHost.install((driver) => ({ get config() { return driver.config; }, + createDatabase: (...a) => driver.createDatabase(...a), get fileColumnsMoved() { return driver.fileColumnsMoved; }, set fileColumnsMoved(v) { driver.fileColumnsMoved = v; }, get fileColumnsMovedAsked() { return driver.fileColumnsMovedAsked; }, @@ -10651,10 +10373,14 @@ export class SqlDriver implements IDataDriver { get fileColumnsMovedDeclared() { return driver.fileColumnsMovedDeclared; }, get fileColumnsMovedResolver() { return driver.fileColumnsMovedResolver; }, set fileColumnsMovedResolver(v) { driver.fileColumnsMovedResolver = v; }, + get isMysql() { return driver.isMysql; }, + get isPostgres() { return driver.isPostgres; }, get isSqlite() { return driver.isSqlite; }, + get knex() { return driver.knex; }, get logger() { return driver.logger; }, mediaColumnIsJson: (...a) => driver.mediaColumnIsJson(...a), get openedEmptyInMemory() { return driver.openedEmptyInMemory; }, + sqliteFilename: (...a) => driver.sqliteFilename(...a), get tenantKeyCertified() { return driver.tenantKeyCertified; }, set tenantKeyCertified(v) { driver.tenantKeyCertified = v; }, get tenantKeyCertifiedReadFailureReported() { return driver.tenantKeyCertifiedReadFailureReported; }, @@ -10700,12 +10426,14 @@ export class SqlDriver implements IDataDriver { get canonicalTimeFields() { return driver.canonicalTimeFields; }, coerceFilterValue: (...a) => driver.coerceFilterValue(...a), coercionKey: (...a) => driver.coercionKey(...a), + compileFilters: (...a) => driver.compileFilters(...a), get dateFields() { return driver.dateFields; }, get datetimeFields() { return driver.datetimeFields; }, filterColumnExpr: (...a) => driver.filterColumnExpr(...a), isJsonColumn: (...a) => driver.isJsonColumn(...a), get isMysql() { return driver.isMysql; }, get isSqlite() { return driver.isSqlite; }, + jsonColumnFieldClass: (...a) => driver.jsonColumnFieldClass(...a), get jsonFields() { return driver.jsonFields; }, get logger() { return driver.logger; }, get managedObjectFields() { return driver.managedObjectFields; }, @@ -10715,6 +10443,8 @@ export class SqlDriver implements IDataDriver { get numericFields() { return driver.numericFields; }, get objectByPhysicalTable() { return driver.objectByPhysicalTable; }, presentReadValue: (...a) => driver.presentReadValue(...a), + get readableIdsDriverName() { return driver.readableIdsDriverName; }, + get readableIdsRefusalLog() { return driver.readableIdsRefusalLog; }, remoteColumn: (...a) => driver.remoteColumn(...a), resolveWithheldFilterRefusal: (...a) => driver.resolveWithheldFilterRefusal(...a), sqliteCanonicalDatetimeSql: (...a) => driver.sqliteCanonicalDatetimeSql(...a), @@ -10734,6 +10464,8 @@ export class SqlDriver implements IDataDriver { isMultiTenantMode: (...a) => driver.isMultiTenantMode(...a), get knex() { return driver.knex; }, get logger() { return driver.logger; }, + get physicalSchemaByObject() { return driver.physicalSchemaByObject; }, + get physicalTableByObject() { return driver.physicalTableByObject; }, remoteColumn: (...a) => driver.remoteColumn(...a), resolveTenantField: (...a) => driver.resolveTenantField(...a), get tenantAuditWarned() { return driver.tenantAuditWarned; }, @@ -10758,6 +10490,7 @@ export class SqlDriver implements IDataDriver { introspectIndexes: (...a) => driver.introspectIndexes(...a), get isMysql() { return driver.isMysql; }, get isPostgres() { return driver.isPostgres; }, + get isSqlite() { return driver.isSqlite; }, get knex() { return driver.knex; }, syncDeclaredIndexes: (...a) => driver.syncDeclaredIndexes(...a), uniqueIndexesFromFields: (...a) => driver.uniqueIndexesFromFields(...a), @@ -10807,6 +10540,11 @@ export class SqlDriver implements IDataDriver { get updatedAtColumnState() { return driver.updatedAtColumnState; }, get valueShapeFields() { return driver.valueShapeFields; }, })); + schemaIntrospectionHost.install((driver) => ({ + get knex() { return driver.knex; }, + get physicalSchemaByObject() { return driver.physicalSchemaByObject; }, + get physicalTableByObject() { return driver.physicalTableByObject; }, + })); schemaSyncSectionHost.install((driver) => ({ assertSchemaMutable: (...a) => driver.assertSchemaMutable(...a), initObjects: (...a) => driver.initObjects(...a), diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index e5f04a82a56..9e8866641f2 100644 --- a/scripts/check-tenant-chokepoint.mjs +++ b/scripts/check-tenant-chokepoint.mjs @@ -274,6 +274,7 @@ const DECLARED_WATCH_HINTS = [ 'packages/drivers/driver-sql/src/sql-driver-raw-execution.ts', 'packages/drivers/driver-sql/src/sql-driver-read-exit-fault.ts', 'packages/drivers/driver-sql/src/sql-driver-rotation.ts', + 'packages/drivers/driver-sql/src/sql-driver-schema-introspection.ts', 'packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts', 'packages/drivers/driver-sql/src/sql-driver-schema-sync.ts', 'packages/drivers/driver-sql/src/sql-driver-sequence-semantics.ts', diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index ab7402b5200..89249b5a767 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -318,6 +318,111 @@ const CLASS_PATH_READER_NEEDLES = Object.freeze({ */ const BOUND_HOST_METHODS = new Set(['getBuilder']); +/** + * Fields a moved body may CALL (#30147). A body that calls a field reaches it + * through the host's getter, so the call's receiver becomes the host, not the + * driver (`field-call`). A field whose value never reads its receiver is + * called the same either way; each row names why. A row naming no field of + * the class stops the script (exit 2). + */ +const RECEIVER_FREE_FIELDS = Object.freeze({ + knex: 'a Knex instance is the `function knex(tableName, options)` closure `makeKnex` returns, which reads `knex.context` and never `this`', +}); + +/** + * Non-private methods whose return type the base leaves inferred, each with + * the type the base's declaration output prints for it (`dist/index.d.ts` of + * `@objectstack/driver-sql` built from the base, copied, never read off the + * source by hand) (#30147). The stub on the class carries it written out + * after its parameter list, so the published declaration does not re-infer + * the type through the facet; the facet member keeps the base text, return + * type absent. The public-surface `.d.ts` reading of the PR proves each one + * prints as it did. A row naming no method, or a method the base already + * annotates, stops the script (exit 2). + */ +const EXPLICIT_RETURN_TYPES = Object.freeze({ + applyFilters: 'void', + createDatabase: 'Promise', + ensureDatabaseExists: 'Promise', + getBuilder: 'knex.Knex.QueryBuilder', +}); + +/** + * Get accessors whose bodies move like methods (#30147): the getter stays on + * the class with its signature, its body becomes the one-call stub, and the + * facet member of the same name is the body. Only getters whose bodies the + * facade gate reads as logic are named; a getter that reads one member or + * forwards one call is a delegation as it stands. A row naming no get + * accessor of the class stops the script (exit 2). + */ +const MOVED_ACCESSORS = Object.freeze({ + dateGranularityCapabilities: 'branches on the dialect', + dialectName: 'branches on the dialect', + insertReturnsStoredRows: 'an operator over two dialect probes', + supportsWalJournal: 'a literal answer a subclass overrides', +}); + +/** + * Instance fields whose initializer moves (#30147): the field stays on the + * class with its declared type, and its initializer becomes one call of the + * facet member of the same name, a function whose body returns the base + * initializer's text exactly, typed as the field (`SqlDriver['x']`). An + * instance initializer runs at construction, after every module has been + * evaluated, so the call never meets a facet in its temporal dead zone; a + * STATIC initializer runs while the class itself is evaluated, so none is + * named here. The initializer must read no `this`. A row naming no such field + * stops the script (exit 2). + */ +const MOVED_FIELD_DEFAULTS = Object.freeze({ + logger: 'the default sink: arrow functions over `console`', +}); + +/** + * A parameter default the facade gate reads as data (`'x'`, `-1`, `CONST.X`, + * `{}`, `[]`, `{ a: 1 }`), the gate's own rule: anything else computes. A + * stub whose parameter computes its default is written with the parameter + * optional instead (`nowMs?: number`), and the default stays where the body + * is, so the value the body sees is the one it saw (#30147). + */ +function isDataDefault(tm, e) { + const { Node, SyntaxKind } = tm; + while (Node.isParenthesizedExpression(e) || Node.isAsExpression(e) || Node.isNonNullExpression(e) || Node.isSatisfiesExpression?.(e)) e = e.getExpression(); + const k = e.getKind(); + if ([SyntaxKind.StringLiteral, SyntaxKind.NumericLiteral, SyntaxKind.NoSubstitutionTemplateLiteral, SyntaxKind.TrueKeyword, + SyntaxKind.FalseKeyword, SyntaxKind.NullKeyword, SyntaxKind.BigIntLiteral].includes(k)) return true; + if (Node.isPrefixUnaryExpression(e)) return Node.isNumericLiteral(e.getOperand()); + if (Node.isIdentifier(e)) return true; + if (Node.isPropertyAccessExpression(e)) { + let x = e; + while (Node.isPropertyAccessExpression(x)) x = x.getExpression(); + return Node.isIdentifier(x) || x.getKind() === SyntaxKind.ThisKeyword; + } + if (Node.isArrayLiteralExpression(e)) return e.getElements().every((x) => isDataDefault(tm, x)); + if (Node.isObjectLiteralExpression(e)) { + return e.getProperties().every((p) => (Node.isPropertyAssignment(p) && !Node.isComputedPropertyName(p.getNameNode()) && isDataDefault(tm, p.getInitializerOrThrow())) + || (Node.isShorthandPropertyAssignment(p) && !p.getObjectAssignmentInitializer())); + } + return false; +} + +/** + * The parameters of a stub that the stub writes optional (#30147): each + * parameter whose default computes, carries a type annotation, and is + * followed only by optional or defaulted parameters. Returns + * `[{ param, text }]`, `text` being what the stub writes in its place. + */ +function optionalizedParams(tm, m) { + const params = m.getParameters(); + const out = []; + params.forEach((p, i) => { + const init = p.getInitializer(); + if (!init || isDataDefault(tm, init) || !p.getTypeNode() || p.isRestParameter()) return; + if (!params.slice(i + 1).every((q) => q.hasQuestionToken() || q.getInitializer() || q.isRestParameter())) return; + out.push({ param: p, text: `${p.getName()}?: ${p.getTypeNode().getText()}` }); + }); + return out; +} + /** Every reason a method stays, with the one-line why the plan prints. */ const STAY_REASONS = Object.freeze({ 'no-section': 'declared above the first section banner, and the class head maps to no module', @@ -331,7 +436,6 @@ const STAY_REASONS = Object.freeze({ 'not-identifier-name': 'a computed, string or #private name', 'inferred-return-type': 'a non-private method whose return type is inferred (the .d.ts would infer it through the module)', 'parameter-shape': 'a destructured or `this` parameter (the stub cannot forward it unchanged)', - 'comment-in-parameters': 'a comment inside its parameter list (one-line parameters would drop it)', 'path-reader': 'a reader takes its text out of sql-driver.ts by path (PATH_READER_PINS / PATH_READER_NEEDLES / CLASS_PATH_READER_NEEDLES)', 'arguments': 'its body reads `arguments`', 'super': 'its body reads `super`', @@ -599,18 +703,19 @@ function readMethod(tm, ctx, m) { const routeBlockers = []; let readsNonPublicStatic = false; let classOther = false; - const result = () => ({ reasons, host, writes, imports, moduleRefs, classStatics, routeBlockers, readsNonPublicStatic, classOther }); + let rawParams = null; + const result = () => ({ reasons, host, writes, imports, moduleRefs, classStatics, routeBlockers, readsNonPublicStatic, classOther, rawParams }); if (m.isStatic() && ctx.members.has(m.getName())) add('static-name-shared'); if (!m.getBody()) add('no-body'); if (m.isAbstract && m.isAbstract()) add('no-body'); - if (m.isGenerator()) add('generator'); - if (m.getOverloads().length > 0) add('overloaded'); + if (typeof m.isGenerator === 'function' && m.isGenerator()) add('generator'); + if (typeof m.getOverloads === 'function' && m.getOverloads().length > 0) add('overloaded'); if (m.getDecorators().length > 0) add('decorated'); const nameNode = m.getNameNode(); if (!Node.isIdentifier(nameNode)) { add('not-identifier-name'); return result(); } const name = nameNode.getText(); - if (!m.getReturnTypeNode() && m.getScope() !== 'private') add('inferred-return-type'); + if (!m.getReturnTypeNode() && m.getScope() !== 'private' && !Object.hasOwn(ctx.returnTypes, name)) add('inferred-return-type'); for (const p of m.getParameters()) { // A defaulted identifier forwards as it is: the stub keeps the default and // hands the body the value it produced. @@ -619,22 +724,18 @@ function readMethod(tm, ctx, m) { const body = m.getBody(); if (!body) return result(); - // A comment between the parentheses that no parameter's own text carries. + // A comment between the parentheses that no parameter's own text carries: + // the facet member then takes the base's parameter list verbatim, comments + // and line breaks included (#30147), instead of one parameter after another. const params = m.getParameters(); - const open = m.getFirstChildByKindOrThrow(SyntaxKind.OpenParenToken); - const close = m.getFirstChildByKindOrThrow(SyntaxKind.CloseParenToken); - let between = ctx.text.slice(open.getEnd(), close.getStart()); - for (const p of [...params].reverse()) { - between = between.slice(0, p.getStart() - open.getEnd()) + between.slice(p.getEnd() - open.getEnd()); - } - if (/\/\/|\/\*/.test(between)) add('comment-in-parameters'); + rawParams = commentInParameters(tm, m) ? parameterListText(tm, m) : null; const methodText = m.getText(); for (const needle of Object.keys(ctx.needles)) if (methodText.includes(needle)) add('path-reader', JSON.stringify(needle)); for (const needle of Object.keys(ctx.classNeedles)) if (methodText.includes(needle)) add('path-reader', JSON.stringify(needle)); if (Object.prototype.hasOwnProperty.call(ctx.pins, name)) add('path-reader', name); - const regions = [...m.getTypeParameters(), ...params, m.getReturnTypeNode(), body].filter(Boolean); + const regions = [...typeParamsOf(m), ...params, m.getReturnTypeNode(), body].filter(Boolean); const checker = ctx.sf.getProject().getTypeChecker().compilerObject; for (const region of regions) { const nodes = [region, ...region.getDescendants()]; @@ -668,7 +769,7 @@ function readMethod(tm, ctx, m) { } else if (info.kind === 'setter') { add('this-write', member); } else { - if (called) add('field-call', member); + if (called && !Object.hasOwn(ctx.receiverFree, member)) add('field-call', member); host.set(member, 'getter'); } continue; @@ -783,6 +884,9 @@ const CLASS_TYPE_ALIAS = `${CLASS_NAME}Class`; /** The placeholder above a moved public or protected body: its docblock stays on the class. */ const placeholderDoc = (name) => `/** The body of \`${CLASS_NAME}#${name}\`; the method's docblock there is its contract. */`; +const fieldDefaultDoc = (name) => `/** The initializer of \`${CLASS_NAME}#${name}\`; the field's declaration there is its contract. */`; +/** A member's type parameters; an accessor has none. */ +const typeParamsOf = (m) => (typeof m.getTypeParameters === 'function' ? m.getTypeParameters() : []); /** * The statics block `sql-driver.ts` carries for the routed facets: the type and @@ -1044,7 +1148,11 @@ function hostUsesOfMovers(tm, ctx, top, methodRecords) { const checker = ctx.sf.getProject().getTypeChecker().compilerObject; const skip = []; for (const r of top.movers) skip.push([r.node.getFullStart(), r.node.getEnd()]); - for (const r of methodRecords) if (r.reasons.length === 0) skip.push([r.node.getBody().getStart(), r.node.getBody().getEnd()]); + for (const r of methodRecords) { + if (r.reasons.length > 0) continue; + const moved = r.kind === 'field-default' ? r.node.getInitializer() : r.node.getBody(); + skip.push([moved.getStart(), moved.getEnd()]); + } const uses = new Map(); for (const node of ctx.sf.getDescendantsOfKind(SyntaxKind.Identifier)) { const at = node.getStart(); @@ -1070,6 +1178,7 @@ function hostUsesOfMovers(tm, ctx, top, methodRecords) { */ function plan(tm, text, { sections = SECTION_MODULES, head = CLASS_HEAD_SECTION, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES, + receiverFree = RECEIVER_FREE_FIELDS, returnTypes = EXPLICIT_RETURN_TYPES, accessors = MOVED_ACCESSORS, fieldDefaults = MOVED_FIELD_DEFAULTS, } = {}) { const sf = parse(tm, HOST_PATH, text); const cls = sf.getClass(CLASS_NAME); @@ -1090,7 +1199,7 @@ function plan(tm, text, { }); } } - const ctx = { text, sf, cls, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf), statics, pins, needles, classNeedles }; + const ctx = { text, sf, cls, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf), statics, pins, needles, classNeedles, receiverFree, returnTypes }; for (const n of [STATICS_NAME, STATICS_HOST_NAME, ...[...Object.values(sections), head && head[1]].filter(Boolean).map((file) => facetNameOf(file))]) { if (ctx.scope.bindings.has(n)) refuse(`${HOST_BASENAME} already binds ${n}, a name this script generates`); } @@ -1102,6 +1211,30 @@ function plan(tm, text, { const staleClassNeedles = Object.keys(classNeedles).filter((n) => !text.includes(n)); if (staleClassNeedles.length > 0) refuse(`CLASS_PATH_READER_NEEDLES names text ${HOST_BASENAME} no longer carries: ${staleClassNeedles.map((n) => JSON.stringify(n)).join(', ')}`); + // The #30147 tables: every row names what the class declares, as it says. + for (const n of Object.keys(receiverFree)) { + if (ctx.members.get(n)?.kind !== 'field') refuse(`RECEIVER_FREE_FIELDS names ${n}, which is no instance field of ${CLASS_NAME}`); + } + for (const n of Object.keys(returnTypes)) { + const ms = cls.getMethods().filter((m) => m.getName() === n && !m.isStatic()); + if (ms.length !== 1) refuse(`EXPLICIT_RETURN_TYPES names ${n}, which is not one instance method of ${CLASS_NAME}`); + if (ms[0].getReturnTypeNode()) refuse(`EXPLICIT_RETURN_TYPES names ${n}, whose return type the base already writes`); + } + const getters = new Map(); + for (const n of Object.keys(accessors)) { + const gs = cls.getGetAccessors().filter((g) => g.getName() === n && !g.isStatic()); + if (gs.length !== 1) refuse(`MOVED_ACCESSORS names ${n}, which is not one instance get accessor of ${CLASS_NAME}`); + getters.set(n, gs[0]); + } + const defaultFields = new Map(); + for (const n of Object.keys(fieldDefaults)) { + const fs = cls.getProperties().filter((f) => f.getName() === n && !f.isStatic()); + if (fs.length !== 1 || !fs[0].getInitializer() || !fs[0].getTypeNode()) { + refuse(`MOVED_FIELD_DEFAULTS names ${n}, which is not one typed instance field of ${CLASS_NAME} with an initializer`); + } + defaultFields.set(n, fs[0]); + } + const top = planTopLevel(tm, ctx); const records = []; @@ -1130,8 +1263,56 @@ function plan(tm, text, { } } if (record.reasons.length === 0) record.stubLines = stubBody(record, record.module, ' ').split('\n').length; + record.kind = 'method'; + record.optionalized = optionalizedParams(tm, m); records.push(record); } + // The get accessors MOVED_ACCESSORS names move as methods do (#30147). + for (const g of getters.values()) { + const section = sectionOf(g); + const read = readMethod(tm, ctx, g); + if (!section) read.reasons.unshift('no-section'); + else if (section[1] === null) read.reasons.unshift('section-kept'); + if (!g.getReturnTypeNode()) read.reasons.push('inferred-return-type'); + const startLine = g.getStartLineNumber(true); + const body = g.getBody(); + const record = { + name: g.getName(), node: g, section: section ? section[0] : null, module: section ? section[1] : null, startLine, + lineCount: g.getEndLineNumber() - startLine + 1, bodyLines: body.getEndLineNumber() - body.getStartLineNumber() + 1, + doc: null, docLines: 0, ...read, kind: 'accessor', optionalized: [], + }; + if (record.reasons.length === 0) record.stubLines = stubBody(record, record.module, ' ').split('\n').length; + records.push(record); + } + // The field initializers MOVED_FIELD_DEFAULTS names (#30147): one that + // reads `this`, or a name of this file (an import or a top-level binding), + // is refused, since the facet member would carry it unedited. + for (const f of defaultFields.values()) { + const section = sectionOf(f); + const init = f.getInitializer(); + const checker = sf.getProject().getTypeChecker().compilerObject; + for (const node of [init, ...init.getDescendants()]) { + if (node.getKind() === tm.SyntaxKind.ThisKeyword) refuse(`MOVED_FIELD_DEFAULTS: the initializer of ${f.getName()} reads \`this\``); + if (node.getKind() !== tm.SyntaxKind.Identifier) continue; + const symbol = checker.getSymbolAtLocation(node.compilerNode); + for (const decl of symbol?.declarations ?? []) { + if (decl.getSourceFile() !== sf.compilerNode) continue; + if (decl.getStart() >= init.getStart() && decl.getEnd() <= init.getEnd()) continue; + refuse(`MOVED_FIELD_DEFAULTS: the initializer of ${f.getName()} reads ${node.getText()}, a name of ${HOST_BASENAME}`); + } + } + const reasons = !section ? ['no-section'] : section[1] === null ? ['section-kept'] : []; + const startLine = f.getStartLineNumber(true); + records.push({ + name: f.getName(), node: f, section: section ? section[0] : null, module: section ? section[1] : null, startLine, + lineCount: f.getEndLineNumber() - startLine + 1, bodyLines: init.getEndLineNumber() - init.getStartLineNumber() + 1, + doc: null, docLines: 0, reasons, host: new Map(), writes: new Set(), imports: new Map(), moduleRefs: new Map(), + classStatics: new Map(), routeBlockers: [], readsNonPublicStatic: false, classOther: false, rawParams: null, + kind: 'field-default', optionalized: [], stubLines: 1, + }); + } + // Facet member order is the class's: methods, accessors and fields by position. + records.sort((a, b) => a.node.getStart() - b.node.getStart()); // The private-static-through-host rule, per facet module: once one mover // reads the statics through `SqlDriverStatics` (imported AS `SqlDriver`), @@ -1305,6 +1486,37 @@ function paramsText(m) { return m.getParameters().map((p) => p.getText()); } +/** The text between a member's parentheses, exactly. */ +function parameterListText(tm, m) { + const { SyntaxKind } = tm; + const open = m.getFirstChildByKindOrThrow(SyntaxKind.OpenParenToken); + const close = m.getFirstChildByKindOrThrow(SyntaxKind.CloseParenToken); + return m.getSourceFile().getFullText().slice(open.getEnd(), close.getStart()); +} + +/** Whether a comment stands between a member's parentheses that no parameter's own text carries. */ +function commentInParameters(tm, m) { + const { SyntaxKind } = tm; + const open = m.getFirstChildByKindOrThrow(SyntaxKind.OpenParenToken); + let between = parameterListText(tm, m); + for (const p of [...m.getParameters()].reverse()) { + between = between.slice(0, p.getStart() - open.getEnd()) + between.slice(p.getEnd() - open.getEnd()); + } + return /\/\/|\/\*/.test(between); +} + +/** + * A facet member's parameter list: its host parameter (when it has one), + * then the base's parameters. One after another, unless a comment stands + * between the base's parentheses: then the base's list verbatim, so the + * comment moves with it (#30147). + */ +function facetParamsText(thisParam, m, raw) { + if (raw === null) return [...(thisParam ? [thisParam] : []), ...paramsText(m)].join(', '); + if (!thisParam) return raw; + return `${thisParam},${/^\s/.test(raw) ? '' : ' '}${raw}`; +} + function forwardList(m) { return m.getParameters().map((p) => (p.isRestParameter() ? `...${p.getName()}` : p.getName())); } @@ -1372,7 +1584,7 @@ function moduleText(moduleFile, title, movers, scope, leafNames, routed = false) } out.push(' */'); out.push(''); - out.push(...importLines(scope, movers, { needsHost, moduleRefs, leafNames, routed })); + out.push(...importLines(scope, movers, { needsHost: needsHost || movers.some((r) => r.kind === 'field-default'), moduleRefs, leafNames, routed })); if (needsHost) { out.push(''); out.push('/**'); @@ -1395,11 +1607,17 @@ function moduleText(moduleFile, title, movers, scope, leafNames, routed = false) movers.forEach((r, i) => { const m = r.node; const picks = [...r.host.keys()].sort().map((k) => `'${k}'`).join(' | '); - const params = [...(r.host.size > 0 ? [`this: Pick<${hostName}, ${picks}>`] : []), ...paramsText(m)]; - const ret = m.getReturnTypeNode() ? `: ${m.getReturnTypeNode().getText()}` : ''; if (i > 0) out.push(''); - const typeParams = m.getTypeParameters().length > 0 ? `<${m.getTypeParameters().map((t) => t.getText()).join(', ')}>` : ''; - out.push(` ${r.doc ?? `${placeholderDoc(r.name)}\n `}static ${m.isAsync() ? 'async ' : ''}${r.name}${typeParams}(${params.join(', ')})${ret} ${m.getBody().getText()}`); + if (r.kind === 'field-default') { + // The field's initializer, returned exactly, typed as the field. + out.push(` ${fieldDefaultDoc(r.name)}\n static ${r.name}(): ${classType}['${r.name}'] {\n return ${m.getInitializer().getText()};\n }`); + return; + } + const thisParam = r.host.size > 0 ? `this: Pick<${hostName}, ${picks}>` : null; + const ret = m.getReturnTypeNode() ? `: ${m.getReturnTypeNode().getText()}` : ''; + const typeParams = typeParamsOf(m).length > 0 ? `<${typeParamsOf(m).map((t) => t.getText()).join(', ')}>` : ''; + const isAsync = typeof m.isAsync === 'function' && m.isAsync(); + out.push(` ${r.doc ?? `${placeholderDoc(r.name)}\n `}static ${isAsync ? 'async ' : ''}${r.name}${typeParams}(${facetParamsText(thisParam, m, r.rawParams)})${ret} ${m.getBody().getText()}`); }); out.push('}'); out.push(''); @@ -1443,6 +1661,9 @@ function leafText(scope, top, leafExports) { const DOC_MOVED = 'its docblock with it'; +/** A moved field initializer's replacement: one call of its facet member. */ +const fieldDefaultCall = (moduleFile, name) => `${facetNameOf(moduleFile)}.${name}()`; + /** * A stub's body: the marker line, then ONE call of the facet member of the * same name. A body that reads the class is called with its facet's host, the @@ -1455,7 +1676,7 @@ function stubBody({ node, name, usesHost, docMoved }, moduleFile, closeIndent) { const hostName = hostNameOf(moduleFile); // A generic method calls its body INSTANTIATED with its own type // parameters (`Function#call` alone would erase them). - const typeArgs = node.getTypeParameters().map((t) => t.getName()); + const typeArgs = typeParamsOf(node).map((t) => t.getName()); const fn = typeArgs.length > 0 ? `(${facetNameOf(moduleFile)}.${name}<${typeArgs.join(', ')}>)` : `${facetNameOf(moduleFile)}.${name}`; const out = ['{']; const args = forwardList(node).join(', '); @@ -1517,6 +1738,12 @@ function generate(tm, text, planned) { `${anyHost ? ` what they read (\`${hostName}\`)` : ' their arguments'}.`); importBlock.push(`\nimport { ${facetNameOf(moduleFile)} } from './${moduleFile.replace(/\.ts$/, '.js')}';`); for (const r of movers) { + if (r.kind === 'field-default') { + // The initializer becomes one call of the facet member that returns it. + const init = r.node.getInitializer(); + edits.push({ start: init.getStart(), end: init.getEnd(), text: fieldDefaultCall(moduleFile, r.name) }); + continue; + } const body = r.node.getBody(); const lineStart = text.lastIndexOf('\n', body.getEnd() - 1) + 1; const closeIndent = text.slice(lineStart, body.getEnd() - 1); @@ -1529,8 +1756,21 @@ function generate(tm, text, planned) { const asyncKw = r.node.getModifiers().find((x) => x.getKind() === tm.SyntaxKind.AsyncKeyword); if (asyncKw) edits.push({ start: asyncKw.getStart(), end: r.node.getNameNode().getStart(), text: '' }); if (r.doc) edits.push({ start: r.node.getStart(true), end: r.node.getStart(), text: '' }); + // An inferred return type the base's declaration output prints, written out (#30147). + if (r.kind === 'method' && !r.node.getReturnTypeNode() && Object.hasOwn(ctx.returnTypes, r.name)) { + const close = r.node.getFirstChildByKindOrThrow(tm.SyntaxKind.CloseParenToken); + edits.push({ start: close.getEnd(), end: close.getEnd(), text: `: ${ctx.returnTypes[r.name]}` }); + } + for (const o of r.optionalized) edits.push({ start: o.param.getStart(), end: o.param.getEnd(), text: o.text }); } } + // A hand-series stub (its body already a seam call) whose parameter + // computes its default takes the same optional spelling; the seam function + // keeps the default (`verify` reads it there) (#30147). + for (const r of planned.records) { + if (r.kind !== 'method' || r.reasons.length === 0 || !r.reasons.every((x) => x.startsWith('already-moved'))) continue; + for (const o of r.optionalized) edits.push({ start: o.param.getStart(), end: o.param.getEnd(), text: o.text }); + } // After the class's last member: the static block that fills the statics // forwarders, then the one that installs the facet hosts (one edit, so the // two keep this order). @@ -1606,7 +1846,7 @@ const sha256 = (text) => createHash('sha256').update(text).digest('hex'); * * Returns `{ failures, moved, movedTop, linesBefore, linesAfter, routedStatics }`; no failure = proved. */ -function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {}) { +function verify(tm, baseText, headText, moduleTexts, { readSibling = null, returnTypes = EXPLICIT_RETURN_TYPES } = {}) { const { Node } = tm; const failures = []; const moved = []; @@ -1693,11 +1933,20 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} const marker = new RegExp(`^\\{\\s*// ${MARK.replace(/[[\]]/g, '\\$&')} The body moved to \`(sql-driver-[a-z0-9-]+\\.ts)\`(, ${DOC_MOVED})?`); const edits = []; - for (const m of headCls.getMethods()) { + // A base member of the head member's kind and name: a method, or a get accessor (#30147). + const baseOf = (m) => (Node.isGetAccessorDeclaration(m) ? baseCls.getGetAccessors() : baseCls.getMethods()) + .find((x) => x.getName() === m.getName() && x.isStatic() === m.isStatic()); + for (const m of [...headCls.getMethods(), ...headCls.getGetAccessors()]) { const body = m.getBody(); if (!body) continue; const hit = marker.exec(body.getText()); - if (!hit) continue; + if (!hit) { + // A stub that is not generated (a hand-series one) may still write a + // parameter optional whose default computes; the seam keeps the default. + const base = baseOf(m); + if (base && Node.isMethodDeclaration(m)) undoOptionalized(m, base, null); + continue; + } const name = m.getName(); const mod = modules.get(hit[1]); const fn = mod && mod.fns.get(name); @@ -1713,16 +1962,23 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} const want = stubBody({ node: m, name, usesHost, docMoved: Boolean(hit[2]) }, hit[1], closeIndent); if (body.getText() !== want) failures.push(`${name}: its stub is not the generated one-call stub (want ${JSON.stringify(want.split('\n').at(-2).trim())})`); } - const base = baseCls.getMethods().find((x) => x.getName() === name && x.isStatic() === m.isStatic()); + const base = baseOf(m); if (!base || !base.getBody()) { failures.push(`${name}: no method of that name with a body at base`); continue; } const fnParams = fn.getParameters().filter((p) => p.getName() !== 'this').map((p) => p.getText()); const baseParams = base.getParameters().map((p) => p.getText()); + // With a comment between the base's parentheses, the facet member carries + // the base's list verbatim after its host parameter (#30147). + const rawSame = !commentInParameters(tm, base) || (() => { + const thisParam = fn.getParameters().find((p) => p.getName() === 'this'); + return facetParamsText(thisParam ? thisParam.getText() : null, base, parameterListText(tm, base)) === parameterListText(tm, fn); + })(); + const asyncOf = (x) => typeof x.isAsync === 'function' && x.isAsync(); const same = { body: fn.getBody().getText() === base.getBody().getText(), - params: JSON.stringify(fnParams) === JSON.stringify(baseParams), + params: JSON.stringify(fnParams) === JSON.stringify(baseParams) && rawSame, returnType: (fn.getReturnTypeNode()?.getText() ?? null) === (base.getReturnTypeNode()?.getText() ?? null), - async: fn.isAsync() === base.isAsync(), - typeParameters: JSON.stringify(fn.getTypeParameters().map((t) => t.getText())) === JSON.stringify(base.getTypeParameters().map((t) => t.getText())), + async: asyncOf(fn) === asyncOf(base), + typeParameters: JSON.stringify(typeParamsOf(fn).map((t) => t.getText())) === JSON.stringify(typeParamsOf(base).map((t) => t.getText())), static: m.isStatic() === base.isStatic(), }; // Docblock ownership: the facet member's leading text is the base @@ -1743,7 +1999,83 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} lines: base.getBody().getEndLineNumber() - base.getBody().getStartLineNumber() + 1, text: fn.getText(), docMoved, ...same, }); edits.push({ start: body.getStart(), end: body.getEnd(), text: fn.getBody().getText() }); - if (fn.isAsync()) edits.push({ start: m.getNameNode().getStart(), end: m.getNameNode().getStart(), text: 'async ' }); + if (asyncOf(fn)) edits.push({ start: m.getNameNode().getStart(), end: m.getNameNode().getStart(), text: 'async ' }); + // A return type the stub writes and the base leaves inferred: only the + // one EXPLICIT_RETURN_TYPES names, undone by deleting it (#30147). + if (Node.isMethodDeclaration(m) && m.getReturnTypeNode() && !base.getReturnTypeNode()) { + if (returnTypes[name] !== m.getReturnTypeNode().getText()) failures.push(`${name}: its stub writes a return type the base leaves inferred and EXPLICIT_RETURN_TYPES does not name`); + const close = m.getFirstChildByKindOrThrow(tm.SyntaxKind.CloseParenToken); + edits.push({ start: close.getEnd(), end: m.getReturnTypeNode().getEnd(), text: '' }); + } + if (Node.isMethodDeclaration(m)) undoOptionalized(m, base, fn); + } + + // A parameter a stub writes optional (`x?: T`) where the base computes its + // default (`x: T = f()`): exactly that spelling, of a computed default, and + // the default kept where the body is: in the facet member (`fn`) or, for a + // hand-series stub, in the seam function its import names. The undo puts the + // callee's parameter text back (#30147). + function undoOptionalized(m, base, fn) { + const hp = m.getParameters(); + const bp = base.getParameters(); + if (hp.length !== bp.length) return; + let seam; + hp.forEach((p, i) => { + const b = bp[i]; + if (p.getText() === b.getText()) return; + const init = b.getInitializer(); + if (!init || isDataDefault(tm, init) || !b.getTypeNode() || p.getInitializer() || !p.hasQuestionToken() + || p.getText() !== `${b.getName()}?: ${b.getTypeNode().getText()}`) return; + let callee = null; + if (fn) { + callee = fn.getParameters().filter((x) => x.getName() !== 'this')[i] ?? null; + } else { + if (seam === undefined) { + seam = null; + const binding = headSf.getImportDeclarations().flatMap((d) => d.getNamedImports().map((s) => [d, s])) + .find(([, s]) => (s.getAliasNode()?.getText() ?? s.getName()) === m.getName()); + const spec = binding ? binding[0].getModuleSpecifierValue() : null; + const src = spec && SEAM_SPECIFIER.test(spec) && readSibling ? readSibling(spec.replace(/^\.\//, '').replace(/\.js$/, '.ts')) : null; + const fnDecl = src ? parse(tm, join(SRC_DIR, spec.replace(/\.js$/, '.ts')), src).getFunction(binding[0].getNamedImports().find((s) => (s.getAliasNode()?.getText() ?? s.getName()) === m.getName()).getName()) : null; + seam = fnDecl ? fnDecl.getParameters().filter((x) => x.getName() !== 'this') : null; + if (!seam) failures.push(`${m.getName()}: its stub writes a parameter optional, and the seam function that keeps the default cannot be read`); + } + callee = seam ? seam[i] ?? null : null; + } + if (!callee) return; + if (callee.getText() !== b.getText()) { failures.push(`${m.getName()}: parameter ${b.getName()} is optional on the stub, but its callee does not keep the base default`); return; } + edits.push({ start: p.getStart(), end: p.getEnd(), text: callee.getText() }); + }); + } + + // A field whose initializer moved (#30147): the head initializer is one call + // of the facet member of the field's name, and that member returns the + // initializer exactly, typed as the field. The undo puts the text back. + for (const f of headCls.getProperties()) { + const init = f.getInitializer(); + const hit = init ? /^([A-Z]\w*Facet)\.(\w+)\(\)$/.exec(init.getText()) : null; + if (!hit || hit[2] !== f.getName() || f.isStatic()) continue; + const name = f.getName(); + const entry = [...modules].find(([file]) => facetNameOf(file) === hit[1]); + const fn = entry && entry[1].fns.get(name); + if (!fn) { failures.push(`${name}: its initializer calls ${hit[1]}.${name}, which no facet module declares`); continue; } + const [file, mod] = entry; + mod.claimed.add(name); + const lead = mod.text.slice(fn.getStart(true), fn.getStart()); + const bodyText = fn.getBody().getText(); + const want = /^\{\n {4}return ([\s\S]*);\n {2}\}$/.exec(bodyText); + const retType = fn.getReturnTypeNode()?.getText() ?? null; + const typeOk = retType === `${CLASS_NAME}['${name}']` || retType === `${CLASS_TYPE_ALIAS}['${name}']`; + const baseField = baseCls.getProperties().find((x) => x.getName() === name && !x.isStatic()); + const ok = Boolean(want) && typeOk && fn.getParameters().length === 0 && !fn.isAsync() && lead === `${fieldDefaultDoc(name)}\n ` + && init.getText() === fieldDefaultCall(file, name) && baseField && baseField.getInitializer(); + if (!ok) { failures.push(`${name}: its moved initializer is not the generated one (a returning member typed as the field, called with no arguments)`); continue; } + const same = want[1] === baseField.getInitializer().getText(); + if (!same) failures.push(`${name}: the facet member does not return the base initializer exactly`); + moved.push({ name, module: file, bodySha: sha256(want[1]), baseBodySha: sha256(baseField.getInitializer().getText()), + lines: baseField.getInitializer().getEndLineNumber() - baseField.getInitializer().getStartLineNumber() + 1, text: fn.getText(), + docMoved: false, body: same, params: true, returnType: true, async: true, typeParameters: true, static: true }); + edits.push({ start: init.getStart(), end: init.getEnd(), text: want[1] }); } // The statics route: the forwarder type and object ahead of the class and @@ -2068,16 +2400,18 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'top-level moves': 11, 'byte-identity proof': 10, 'refusals': 6, + 'codemod-limit retirements (#30147)': 31, }); -const SELF_TEST_BATTERY_FLOOR = 5; +const SELF_TEST_BATTERY_FLOOR = 6; const FIXTURE_SECTIONS = Object.freeze({ 'Lifecycle': 'sql-driver-lifecycle.ts', 'Kept': null }); const FIXTURE_HEAD = Object.freeze(['Head', 'sql-driver-head.ts']); const FIXTURE_NEEDLES = Object.freeze({ 'PINNED_TEXT': 'a fixture reader', 'needleInMethod(': 'a fixture reader' }); const FIXTURE_CLASS_NEEDLES = Object.freeze({ 'classReaderText(': 'a fixture reader that also reads the leaf' }); const FIXTURE_PINS = Object.freeze({ pinnedMethod: 'a fixture reader' }); -const fixturePlan = (tm, text, head = null) => plan(tm, text, - { sections: FIXTURE_SECTIONS, head, pins: FIXTURE_PINS, needles: FIXTURE_NEEDLES, classNeedles: FIXTURE_CLASS_NEEDLES }); +const NO_RETIREMENT_TABLES = Object.freeze({ receiverFree: {}, returnTypes: {}, accessors: {}, fieldDefaults: {} }); +const fixturePlan = (tm, text, head = null, tables = NO_RETIREMENT_TABLES) => plan(tm, text, + { sections: FIXTURE_SECTIONS, head, pins: FIXTURE_PINS, needles: FIXTURE_NEEDLES, classNeedles: FIXTURE_CLASS_NEEDLES, ...tables }); function fixtureSource(extraMethods = '') { const pad = Array.from({ length: 12 }, (_, i) => ` const p${i} = ${i};`).join('\n'); @@ -2414,16 +2748,129 @@ async function selfTest() { out.host.replace('SECRET: { get: () => SqlDriver.SECRET },', 'SECRET: { value: SqlDriver.SECRET },'), out.modules).failures.length > 0); } + open('codemod-limit retirements (#30147)'); + { + const pad = Array.from({ length: 12 }, (_, i) => ` const p${i} = ${i};`).join('\n'); + const extra = ` + protected knexCall(object: string): string { +${pad} + return this.knex(object); + } + + public spelled(object: string) { +${pad} + return this.other(object); + } + + protected commented(object: string /* the table */, n: number): string { +${pad} + return object + n; + } + + public get movedGetter(): string { +${pad} + return this.count > 0 ? 'a' : 'b'; + } + + protected computedDefault(name: string, nowMs: number = Date.now()): number { +${pad} + return nowMs + name.length; + } + + protected seamed(name: string, nowMs: number = Date.now()): number { + return seamed(name, nowMs); + } +`; + const input = fixtureSource(extra).replace("import { seamHelper } from './sql-driver-seam.js';", + "import { seamHelper, seamed } from './sql-driver-seam.js';"); + const SEAM = 'export function seamed(name: string, nowMs: number = Date.now()): number {\n return nowMs + name.length;\n}\n'; + const sibling = (text) => (rel) => (rel === 'sql-driver-seam.ts' ? text : null); + const TABLES = { receiverFree: { knex: 'a fixture closure' }, returnTypes: { spelled: 'string' }, + accessors: { movedGetter: 'a fixture branch' }, fieldDefaults: { logger: 'a fixture sink' } }; + const without = fixturePlan(tm, input, FIXTURE_HEAD); + const planned = fixturePlan(tm, input, FIXTURE_HEAD, TABLES); + const rec = (p, name) => p.records.find((r) => r.name === name); + const why = (p, name) => (rec(p, name)?.reasons ?? ['']).join('; '); + check('with no table, the knex call and the inferred return type stay (field-call, inferred-return-type)', + why(without, 'knexCall').startsWith('field-call') && why(without, 'spelled').startsWith('inferred-return-type') && !rec(without, 'movedGetter'), + `${why(without, 'knexCall')} | ${why(without, 'spelled')}`); + check('a call of a receiver-free field moves', why(planned, 'knexCall') === '', why(planned, 'knexCall')); + check('a method EXPLICIT_RETURN_TYPES names moves', why(planned, 'spelled') === '', why(planned, 'spelled')); + check('a comment between the parentheses no longer keeps a method on the class', why(planned, 'commented') === '' && why(without, 'commented') === '', why(planned, 'commented')); + check('a named get accessor moves as an accessor', why(planned, 'movedGetter') === '' && rec(planned, 'movedGetter').kind === 'accessor', why(planned, 'movedGetter')); + check('a named field default moves into the class-head module', why(planned, 'logger') === '' && rec(planned, 'logger').kind === 'field-default' + && rec(planned, 'logger').module === 'sql-driver-head.ts', why(planned, 'logger')); + check('a computed default is written optional on the stub, a data default is not', + rec(planned, 'computedDefault').optionalized.map((o) => o.text).join() === 'nowMs?: number' && rec(planned, 'defaulted').optionalized.length === 0); + check('a hand-series stub stays (already-moved) and takes the optional spelling too', + why(planned, 'seamed').startsWith('already-moved') && rec(planned, 'seamed').optionalized.map((o) => o.text).join() === 'nowMs?: number', why(planned, 'seamed')); + + const out = generate(tm, input, planned); + const mod = out.modules.get('sql-driver-lifecycle.ts') ?? ''; + const head = out.modules.get('sql-driver-head.ts') ?? ''; + check('the stub writes the named return type; the facet member keeps the base text', + /\n public spelled\(object: string\): string \{\n/.test(out.host) && /\n static spelled\(this: Pick]*>, object: string\) \{\n/.test(mod)); + check('the facet member carries the base parameter list verbatim, comment and all', + /\n static commented\(this: Pick'])[0]); + check('the getter keeps its signature and its body becomes the one-call stub', + /\n public get movedGetter\(\): string \{\n \/\/ \[#29520 codemod\] The body moved to `sql-driver-lifecycle\.ts`[^\n]*\n return LifecycleFacet\.movedGetter\.call\(lifecycleHost\.of\(this\)\);\n \}\n/.test(out.host), + (out.host.match(/get movedGetter[\s\S]{0,200}/) ?? [''])[0]); + check('the stub writes the computed default optional and the facet member keeps the default', + /\n protected computedDefault\(name: string, nowMs\?: number\): number \{\n/.test(out.host) && /\n static computedDefault\(name: string, nowMs: number = Date\.now\(\)\): number \{\n/.test(mod)); + check('the hand-series stub writes the parameter optional and keeps its seam call', + out.host.includes(' protected seamed(name: string, nowMs?: number): number {\n return seamed(name, nowMs);\n }')); + check('the field keeps its type and calls the facet member; the member returns the initializer typed as the field', + out.host.includes(" protected logger: { warn(m: string): void } = HeadFacet.logger();") && + head.includes(` ${fieldDefaultDoc('logger')}\n static logger(): SqlDriver['logger'] {\n return console;\n }`), head.slice(0, 600)); + check('the knex call moves as written, through the host', mod.includes(' return this.knex(object);')); + + const again = generate(tm, input, fixturePlan(tm, input, FIXTURE_HEAD, TABLES)); + check('two runs on one input write byte-identical trees', + treeHash(new Map([['h', out.host], ...out.modules])) === treeHash(new Map([['h', again.host], ...again.modules]))); + const opts = { readSibling: sibling(SEAM), returnTypes: TABLES.returnTypes }; + const v = verify(tm, input, out.host, out.modules, opts); + const names = v.moved.map((m) => m.name); + check('the generated split proves against its base, each retired mechanism among the moved', + v.failures.length === 0 && ['knexCall', 'spelled', 'commented', 'movedGetter', 'computedDefault', 'logger'].every((n) => names.includes(n)), + [...v.failures, `moved: ${names.join()}`].join(' | ')); + const failsWith = (host, modules = out.modules, o = opts) => verify(tm, input, host, modules, o).failures.length > 0; + const withMod = (file, text) => new Map([...out.modules, [file, text]]); + check('a return type EXPLICIT_RETURN_TYPES does not name fails', failsWith(out.host, out.modules, { ...opts, returnTypes: {} })); + check('an edited written return type fails', failsWith(out.host.replace('public spelled(object: string): string {', 'public spelled(object: string): String {'))); + check('an edited comment in the moved parameter list fails', + failsWith(out.host, withMod('sql-driver-lifecycle.ts', mod.replace('/* the table */', '/* a table */')))); + check('an edited getter body fails', failsWith(out.host, withMod('sql-driver-lifecycle.ts', mod.replace("this.count > 0 ? 'a' : 'b'", "this.count > 1 ? 'a' : 'b'")))); + check('an optional parameter spelled with another type fails', failsWith(out.host.replace('nowMs?: number): number {\n // [#29520', 'nowMs?: any): number {\n // [#29520'))); + check('a facet member that drops the computed default fails', + failsWith(out.host, withMod('sql-driver-lifecycle.ts', mod.replace('static computedDefault(name: string, nowMs: number = Date.now()): number {', 'static computedDefault(name: string, nowMs: number = 0): number {')))); + check('a hand-series stub whose seam drops the default fails', failsWith(out.host, out.modules, { ...opts, readSibling: sibling(SEAM.replace(' = Date.now()', '?')) })); + check('a hand-series optional parameter with no seam to read fails', failsWith(out.host, out.modules, { ...opts, readSibling: null })); + check('an edited moved initializer fails', failsWith(out.host, withMod('sql-driver-head.ts', head.replace(' return console;', ' return globalThis.console;')))); + check('a field calling a member of another name fails', failsWith(out.host.replace('= HeadFacet.logger();', '= HeadFacet.other();'))); + + const refusedWith = (tables) => refused(() => fixturePlan(tm, input, FIXTURE_HEAD, { ...TABLES, ...tables })); + check('RECEIVER_FREE_FIELDS naming a method or no member refuses', + refusedWith({ receiverFree: { other: 'x' } }) && refusedWith({ receiverFree: { gone: 'x' } })); + check('EXPLICIT_RETURN_TYPES naming an annotated or absent method refuses', + refusedWith({ returnTypes: { other: 'string' } }) && refusedWith({ returnTypes: { gone: 'string' } })); + check('MOVED_ACCESSORS naming no get accessor refuses', refusedWith({ accessors: { other: 'x' } }) && refusedWith({ accessors: { gone: 'x' } })); + check('MOVED_FIELD_DEFAULTS naming an untyped field, an initializer reading `this` or a name of the file refuses', + refusedWith({ fieldDefaults: { fn: 'x' } }) && refusedWith({ fieldDefaults: { gone: 'x' } }) && + refused(() => fixturePlan(tm, input.replace(' private count = 0;', ' private count = 0;\n private self: number = this.count;'), FIXTURE_HEAD, { ...TABLES, fieldDefaults: { self: 'x' } })) && + refused(() => fixturePlan(tm, input.replace(' private count = 0;', ' private count = 0;\n private local: number = LOCAL_VALUE;'), FIXTURE_HEAD, { ...TABLES, fieldDefaults: { local: 'x' } }))); + } + open('refusals'); { const unknownBanner = fixtureSource('\n // ── Unnamed section ──────────\n\n protected z(): number {\n return 1;\n }\n'); check('a banner the section table does not name refuses', refused(() => fixturePlan(tm, unknownBanner))); const named = fixtureSource('\n // ── Lifecycle ──────────\n\n protected z(): number {\n return 1;\n }\n'); check('a banner in the dash spelling the table names is read, not refused', !refused(() => fixturePlan(tm, named))); - check('a pin naming what the file lacks refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, head: null, pins: { gone: 'x' }, needles: {}, classNeedles: {} }))); + check('a pin naming what the file lacks refuses', refused(() => plan(tm, fixtureSource(), { ...NO_RETIREMENT_TABLES, sections: FIXTURE_SECTIONS, head: null, pins: { gone: 'x' }, needles: {}, classNeedles: {} }))); check('a class-scoped needle the file no longer carries refuses', - refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, head: null, pins: {}, needles: {}, classNeedles: { 'gone text': 'x' } }))); - check('a needle the file no longer carries refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, head: null, pins: {}, needles: { 'gone text': 'x' }, classNeedles: {} }))); + refused(() => plan(tm, fixtureSource(), { ...NO_RETIREMENT_TABLES, sections: FIXTURE_SECTIONS, head: null, pins: {}, needles: {}, classNeedles: { 'gone text': 'x' } }))); + check('a needle the file no longer carries refuses', refused(() => plan(tm, fixtureSource(), { ...NO_RETIREMENT_TABLES, sections: FIXTURE_SECTIONS, head: null, pins: {}, needles: { 'gone text': 'x' }, classNeedles: {} }))); // `--help` reads no tree: it answers before the "already split" guard, so // it exits 0 whether sql-driver.ts on disk is split (as on a branch) or not. check('--help is answered, never refused, whatever sql-driver.ts holds', (await main(['--help']).catch(() => 2)) === 0); diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 4dd766d44f2..41c20471d3f 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -383,17 +383,11 @@ "card": "#30020", "reason": "state-owning: gate reading, constructor logic (strips the ObjectStack options off the config, branches on fileColumnsMoved, builds Knex; the state is the instance's)" }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "logger", - "card": "#30147", - "reason": "codemod limit: gate reading, the default sink's field initializer holds arrow functions (the default can be built by a module function)" - }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "member": "static DIALECT_CONNECT_TIMEOUT", "card": "#30147", - "reason": "codemod limit: gate reading, a static table whose initializer holds arrow functions (the initializer can move to a module)" + "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", @@ -405,55 +399,19 @@ "file": "packages/drivers/driver-sql/src/sql-driver.ts", "member": "get supports", "card": "#30147", - "reason": "accessor (#30105 report: the codemod moves methods, not accessors)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "get insertReturnsStoredRows", - "card": "#30147", - "reason": "accessor (#30105 report: the codemod moves methods, not accessors)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "get dateGranularityCapabilities", - "card": "#30147", - "reason": "accessor (#30105 report: the codemod moves methods, not accessors)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "get supportsWalJournal", - "card": "#30147", - "reason": "accessor (#30105 report: the codemod moves methods, not accessors)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "get dialectName", - "card": "#30147", - "reason": "accessor (#30105 report: the codemod moves methods, not accessors)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "rotateShards", - "card": "#30147", - "reason": "gate reading: a parameter default computes (`nowMs = Date.now()`, the signature stays byte-for-byte)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "ensureRotation", - "card": "#30147", - "reason": "gate reading: a parameter default computes (`nowMs = Date.now()`; a hand-series stub, #25521 cut 17)" + "reason": "codemod limit: inferred-return-type, a get accessor whose object type the base leaves inferred, with a docblock on each key (MOVED_ACCESSORS moves a getter whose type is written, #30147 phase 2a)" }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "member": "ensureShardTable", "card": "#30147", - "reason": "path-reader (#30105 report: packages/cli generate-* pins read its text in sql-driver.ts); field-call: knex" + "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": "initObjects", "card": "#30147", - "reason": "path-reader (#30105 report: packages/cli generate-* pins read its text in sql-driver.ts); field-call: knex" + "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", @@ -491,59 +449,11 @@ "card": "#30147", "reason": "path-reader (#30105 report: check-keyed-text-bounds and the packages/cli generate-* pins slice its type switch in sql-driver.ts); inferred-return-type" }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "listPhysicalColumns", - "card": "#30147", - "reason": "field-call: knex (#30105 report: stays on the class)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "getBuilder", - "card": "#30147", - "reason": "field-call: knex; inferred-return-type (#30105 report: stays on the class)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "explainUnkeyableTextColumn", - "card": "#30147", - "reason": "field-call: knex (#30105 report: stays on the class)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "explainHashShadowDuplicate", - "card": "#30147", - "reason": "field-call: knex (#30105 report: stays on the class)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "applyFilters", - "card": "#30147", - "reason": "inferred-return-type (#30105 report: stays on the class until the codemod spells the return type)" - }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "member": "applyFilterCondition", "card": "#30147", - "reason": "inferred-return-type; comment-in-parameters (#30105 report: stays on the class)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "ensureDatabaseExists", - "card": "#30147", - "reason": "inferred-return-type (#30105 report: stays on the class until the codemod spells the return type)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "createDatabase", - "card": "#30147", - "reason": "inferred-return-type (#30105 report: stays on the class until the codemod spells the return type)" - }, - { - "file": "packages/drivers/driver-sql/src/sql-driver.ts", - "member": "assertOperatorAppliesToColumn", - "card": "#30147", - "reason": "comment-in-parameters (#30105 report: stays on the class)" + "reason": "inferred-return-type (#30105 report: stays on the class until its return type is spelled, #30147 phase 2d); its comment-in-parameters half is retired (#30147 phase 2a)" } ] }