diff --git a/packages/drivers/driver-sql/src/sql-driver-aggregation.ts b/packages/drivers/driver-sql/src/sql-driver-aggregation.ts index a37f2a8ae1a..75187c12b85 100644 --- a/packages/drivers/driver-sql/src/sql-driver-aggregation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-aggregation.ts @@ -2,31 +2,37 @@ /** * [#29520 codemod] The "Aggregation" section of `SqlDriver`: the bodies of - * `aggregate`, `foldEmptyAggregateAnswers` moved out of `sql-driver.ts` whole - * by `scripts/codemod-split-sql-driver.mjs`. + * `aggregate`, `aggregandColumnClassOf`, `foldEmptyAggregateAnswers` moved out + * of `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link AggregationFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 AggregationHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `aggregationHost` 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. + * 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 { emptyGroupValueFor } from '@objectstack/spec/data'; import type { DriverOptions } from '@objectstack/spec/data'; import type { DriverQuery } from '@objectstack/spec/contracts'; import { AGGREGATE_ANSWER_KIND, aggregandOperandSql } from '@objectstack/core'; +import type { AggregandColumnClass } from '@objectstack/core'; import { refuseDateBucketedGroupBy, refuseDistinctAggregateWithoutField, unsupportedAggregationFilterError, } from './sql-driver-top-level.js'; @@ -34,10 +40,11 @@ import type { ReadPresentationKind } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link AggregationFacet} 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 AggregationHost { aggregandColumnClassOf: SqlDriver['aggregandColumnClassOf']; @@ -45,21 +52,26 @@ export interface AggregationHost { aliasIdentifierSql: SqlDriver['aliasIdentifierSql']; applyFilters: SqlDriver['applyFilters']; applyTenantScope: SqlDriver['applyTenantScope']; + readonly booleanFields: SqlDriver['booleanFields']; buildDateBucketExpr: SqlDriver['buildDateBucketExpr']; coercionKey: SqlDriver['coercionKey']; readonly config: SqlDriver['config']; readonly dateGranularityCapabilities: SqlDriver['dateGranularityCapabilities']; readonly dialectName: SqlDriver['dialectName']; foldEmptyAggregateAnswers: SqlDriver['foldEmptyAggregateAnswers']; + readonly fractionalNumericFields: SqlDriver['fractionalNumericFields']; getBuilder: SqlDriver['getBuilder']; readonly knex: SqlDriver['knex']; mapAggregateFunc: SqlDriver['mapAggregateFunc']; + readonly numericFields: SqlDriver['numericFields']; presentReadColumns: SqlDriver['presentReadColumns']; readPresentationKind: SqlDriver['readPresentationKind']; } -/** The body of `SqlDriver#aggregate`; the method's docblock there is its contract. */ -export async function aggregate(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { +/** The bodies of the "Aggregation" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class AggregationFacet { + /** The body of `SqlDriver#aggregate`; the method's docblock there is its contract. */ + static async aggregate(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { const builder = this.getBuilder(object, options); this.applyTenantScope(builder, object, options); @@ -331,8 +343,17 @@ export async function aggregate(this: Pick): any { + /** The body of `SqlDriver#aggregandColumnClassOf`; the method's docblock there is its contract. */ + static aggregandColumnClassOf(this: Pick, table: string | null, field: string): AggregandColumnClass | undefined { + if (table === null) return undefined; + if (this.booleanFields[table]?.includes(field)) return 'boolean'; + if (this.fractionalNumericFields[table]?.includes(field)) return 'fractional'; + if (this.numericFields[table]?.includes(field)) return 'integral'; + return undefined; + } + + /** The body of `SqlDriver#foldEmptyAggregateAnswers`; the method's docblock there is its contract. */ + static foldEmptyAggregateAnswers(rows: any, identities: Map): any { if (identities.size === 0 || !Array.isArray(rows)) return rows; for (const row of rows) { if (!row || typeof row !== 'object') continue; @@ -342,3 +363,4 @@ export function foldEmptyAggregateAnswers(rows: any, identities: Map, object: string, data: any[], options?: DriverOptions): Promise[]> { +/** The bodies of the "Bulk & Batch Operations" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class BulkOperationsFacet { + /** The body of `SqlDriver#bulkCreate`; the method's docblock there is its contract. */ + static async bulkCreate(this: Pick, object: string, data: any[], options?: DriverOptions): Promise[]> { // Same client-side id assignment as create() (id/_id normalization, // nanoid fallback when neither is supplied) — a row missing an id must // not be silently inserted with a NULL primary key just because the @@ -225,8 +233,8 @@ export async function bulkCreate(this: Pick, object: string, updates: Array<{ id: string | number; data: Record }>, options?: DriverOptions): Promise[]> { + /** The body of `SqlDriver#bulkUpdate`; the method's docblock there is its contract. */ + static async bulkUpdate(this: Pick, object: string, updates: Array<{ id: string | number; data: Record }>, options?: DriverOptions): Promise[]> { // An empty batch issues no statement, exactly as before: a BEGIN/COMMIT // pair — and the pool checkout behind it — buys nothing for zero rows. if (updates.length === 0) return []; @@ -247,8 +255,18 @@ export async function bulkUpdate(this: Pick applyAll({ ...options, transaction: trx })); } -/** The body of `SqlDriver#updateMany`; the method's docblock there is its contract. */ -export async function updateMany(this: Pick, object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise { + /** The body of `SqlDriver#bulkDelete`; the method's docblock there is its contract. */ + static async bulkDelete(this: Pick, object: string, ids: Array, options?: DriverOptions): Promise { + this.auditMissingTenant(object, 'bulkDelete', options); + for (const target of this.rotationShardsOf(object) ?? [object]) { + const builder = this.getBuilder(target, options).whereIn('id', ids); + this.applyTenantScope(builder, object, options); + await builder.delete(); + } + } + + /** The body of `SqlDriver#updateMany`; the method's docblock there is its contract. */ + static async updateMany(this: Pick, object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise { this.auditMissingTenant(object, 'updateMany', options); // `data ?? {}` keeps the nullish tolerance this door already had on the @@ -289,8 +307,21 @@ export async function updateMany(this: Pick, object: string, shards: string[], id: string | number, data: Record, options?: DriverOptions): Promise | null> { + /** The body of `SqlDriver#deleteMany`; the method's docblock there is its contract. */ + static async deleteMany(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise { + this.auditMissingTenant(object, 'deleteMany', options); + let total = 0; + for (const target of this.rotationShardsOf(object) ?? [object]) { + const builder = this.getBuilder(target, options); + this.applyTenantScope(builder, object, options); + if (query.where) this.applyFilters(builder, query.where, options); + total += (await builder.delete()) || 0; + } + return total; + } + + /** The body of `SqlDriver#rotatedUpdateById`; the method's docblock there is its contract. */ + static async rotatedUpdateById(this: Pick, object: string, shards: string[], id: string | number, data: Record, options?: DriverOptions): Promise | null> { const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data)); // [commit 479fba50d] One definition of the decision, shared with {@link update}. No // fallback is threaded here, and that is a property of the path rather than @@ -319,8 +350,8 @@ export async function rotatedUpdateById(this: Pick, object: string, query?: DriverQuery, options?: DriverOptions): Promise { + /** The body of `SqlDriver#count`; the method's docblock there is its contract. */ + static async count(this: Pick, object: string, query?: DriverQuery, options?: DriverOptions): Promise { const builder = this.getBuilder(object, options); this.applyTenantScope(builder, object, options); @@ -352,3 +383,4 @@ export async function count(this: Pick, alias: string): string { + return (this.knex.client as { wrapIdentifier(value: string): string }).wrapIdentifier(String(alias)); + } + + /** 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); + return bucket ? this.knex.raw(bucket.sql, bucket.bindings).toQuery() : null; + } + + /** + * Apply {@link SqliteAbsentFileMode} to the Knex config, redirecting a + * missing SQLite file to `:memory:` before Knex ever opens it. + * + * Has to happen HERE rather than in `connect()`: the filename is baked into + * the Knex instance at construction, and better-sqlite3 opens (and therefore + * creates) the file on the first statement — which for this driver is the + * `PRAGMA auto_vacuum` in {@link connect}. + * + * A file the judgement refuses — one that can be neither opened nor created + * (#24961) — throws its `SqliteDatabaseFileError` out of the constructor, so + * no Knex instance is ever built over it. + * + * Only a SQLite client is asked (#24961). A server dialect's `connection` + * string is a URL, never a filename: handed to the judgement it read as an + * absent file, and a `pg` driver given this option had its connection string + * rewritten to `:memory:` — against {@link SqlDriverConfig.sqliteAbsentFile}'s + * "ignored for non-SQLite dialects". + */ + static knexConfigFor(this: Pick, knexConfig: Knex.Config, mode: SqliteAbsentFileMode | undefined): Knex.Config { + if (mode !== 'empty-in-memory' || !this.isSqlite) return knexConfig; + const conn = (knexConfig as { connection?: unknown }).connection; + const declared = typeof conn === 'string' ? conn : (conn as { filename?: unknown })?.filename; + if (typeof declared !== 'string') return knexConfig; + const resolved = resolveSqliteAbsentFileTarget(declared, mode); + if (!resolved.openedEmptyInMemory) return knexConfig; + this.openedEmptyInMemory = true; + return { + ...knexConfig, + connection: typeof conn === 'string' + ? resolved.filename + : { ...(conn as object), filename: resolved.filename }, + } as Knex.Config; + } + + /** The body of `SqlDriver#withConnectBound`; the method's docblock there is its contract. */ + static withConnectBound(knexConfig: Record): Record { + const pool = knexConfig.pool as Record | undefined; + const bounded: Record = + pool?.createTimeoutMillis === undefined + ? { + ...knexConfig, + pool: { ...(pool ?? {}), createTimeoutMillis: SqlDriver.DEFAULT_CREATE_TIMEOUT_MS }, + } + : { ...knexConfig }; // host chose its own bound — respect it + + // `!dialect` — sqlite, or a client with no connect-timeout knob — means + // there is no TIMEOUT to inject. It deliberately does not skip the session + // pins below: those answer a different question (what does a value MEAN on + // this connection), and the two lists are not the same list. + // + // ⚠️ The worked example that used to stand here was RETIRED by #11784, + // which gave `redshift` the connect-timeout row it was missing. Until then + // `redshift` was in {@link POSTGRES_WIRE_CLIENTS} (so it needed #11389's + // calendar-day pin) yet absent from {@link DIALECT_CONNECT_TIMEOUT} — so a + // `return` placed at this point silently opted it out of a fix it needed. + // That was measured, not hypothetical, which is why the example is recorded + // here rather than dropped. + // + // The hazard outlives the example. Every client needing a session pin + // happens to have a timeout row TODAY; that is current membership, not a + // property either table promises. The next pg-wire client added without a + // connect-timeout knob restores the exact bug — and it would fail the way + // this one did, in silence, with every test green. So: no `return` here, + // and the two tables stay separate. The tripwire that goes red the moment + // those memberships diverge again lives in + // `sql-driver-11389-date-tz-skew.test.ts`. + const dialect = SqlDriver.DIALECT_CONNECT_TIMEOUT[String(knexConfig.client ?? '')]; + if (dialect) { + const conn = knexConfig.connection; + if (typeof conn === 'string') { + // The URL must move into the dialect's own URL slot so the timeout can + // ride alongside it. Verified for both dialects: the connection attempt + // still goes to the URL's host/port, `?sslmode=` is still honoured. + bounded.connection = { + [dialect.urlKey]: conn, + [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS, + }; + } else if (conn && typeof conn === 'object' && (conn as any)[dialect.key] === undefined) { + bounded.connection = { ...(conn as object), [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS }; + } + // A function-valued `connection` (knex's per-acquire provider) is left + // alone: the host is building each connection itself and owns its timeouts. + } + return SqlDriver.withPostgresCalendarDayAsText( + SqlDriver.withMysqlCalendarDayAsText(SqlDriver.withUtcSession(bounded)), + ); + } + + /** + * Pin a MySQL connection to UTC, in both directions (#3942). + * + * MySQL is the one supported dialect where the SESSION timezone participates + * in what a datetime means, on two independent layers, and both default to + * something machine-dependent: + * + * - **mysql2** (`connection.timezone`, default `'local'`) decides how a bound + * JS `Date` is rendered and how a returned `DATETIME` string is parsed back. + * Left at `'local'`, two app servers in different zones write the same + * instant as different values, and read the same row as different instants. + * - **The server** (`@@session.time_zone`, default `SYSTEM`) decides how a + * zone-naive literal is interpreted for a `TIMESTAMP` column, and what + * `CURRENT_TIMESTAMP` renders. Measured at 8 hours off on a server + * configured `+08:00`. + * + * Setting both to UTC makes the wall clock the driver writes *be* the instant, + * which is what {@link mysqlDatetimeLiteral} relies on — and it keeps legacy + * `TIMESTAMP` columns correct too, so a deployment stays right whether or not + * the `DATETIME(3)` migration has run. + * + * A host that set either one explicitly is left alone; an existing + * `pool.afterCreate` is chained rather than replaced, since it is a documented + * knex extension point the host may already be using. + */ + static withUtcSession(knexConfig: Record): Record { + // The MySQL identity question again, asked from a static: the same set + // `isMysql` reads, not a fourth hand-written copy of it (#11550). + if (!SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(knexConfig))) return knexConfig; + + const out: Record = { ...knexConfig }; + const conn = out.connection; + if (conn && typeof conn === 'object' && (conn as any).timezone === undefined) { + out.connection = { ...(conn as object), timezone: 'Z' }; + } + + const pool = (out.pool ?? {}) as Record; + const hostAfterCreate = pool.afterCreate as + | ((conn: unknown, done: (err?: unknown) => void) => void) + | undefined; + out.pool = { + ...pool, + afterCreate(connection: any, done: (err?: unknown, conn?: unknown) => void) { + connection.query(`SET time_zone = '+00:00'`, (err: unknown) => { + if (err) return done(err); + if (!hostAfterCreate) return done(undefined, connection); + hostAfterCreate(connection, (hostErr?: unknown) => done(hostErr, connection)); + }); + }, + }; + return out; + } + + /** + * Keep a MySQL `DATE` a calendar-day STRING, never a JS `Date` (#20280). + * + * The MySQL counterpart of {@link withPostgresCalendarDayAsText}: the wire + * form of a `DATE` IS `YYYY-MM-DD`, so the driver asks mysql2 for that text + * (`dateStrings: ['DATE']`) and presents it through {@link toDateOnly} — + * `@objectstack/core`'s `temporalStorageForm`, the rule the write and + * `where` paths already apply. A `date` column then reaches the read doors + * as TEXT on every dialect. + * + * ## The measurement + * + * mysql2 3.23's `Packet#parseDate` rebuilds a `DATE` from its three numbers: + * `new Date(Date.UTC(y, m - 1, d))` under the `timezone: 'Z'` pin of + * {@link withUtcSession}, `new Date(y, m - 1, d)` under the default + * `'local'`. Both constructors read a year from 0 to 99 as 1900 + year. + * Measured on MySQL 8.0.46 (server `time_zone='+08:00'`) through `find()`: + * + * | stored (`CAST(d AS CHAR)`) | mysql2 materialised | presented before | presented now | + * |---|---|---|---| + * | `0009-03-04` | `1909-03-04T00:00:00.000Z` | `1909-03-04` | `0009-03-04` | + * | `0099-03-04` | `1999-03-04T00:00:00.000Z` | `1999-03-04` | `0099-03-04` | + * | `0999-06-15` | `0999-06-15T00:00:00.000Z` | `0999-06-15` | `0999-06-15` | + * | `2026-03-04` | `2026-03-04T00:00:00.000Z` | `2026-03-04` | `2026-03-04` | + * + * The write was right and the read was wrong: `where d $eq '0009-03-04'` + * found the row and presented `1909-03-04`. A year from 1000 to 9999 presents + * exactly what it did. A zero day (`0000-00-00`, storable only with + * `NO_ZERO_DATE` off) presents as that text, where mysql2 invented + * `1899-11-30`. + * + * ## Why `DATE` and not `DATETIME` + * + * `dateStrings` also takes `'DATETIME'`, and a `DATETIME` in years 0..99 is + * misread too: `parseDateTime` hands `'0009-03-04 10:00:00.000Z'` to V8's + * non-ISO `Date` parser, which answers 2004-09-03. But a `DATETIME` is an + * instant, and ADR-0053 D-F2 keeps the client parser's `Date` for an instant, + * folded to text only at the driver's own read doors — text at the client + * parser is the option that ADR did not take. That half is not decided here. + * + * The other measured remedy, a `'+00:00'` zone, takes mysql2's padded + * string-constructor arm for a `DATE` but keeps the `Date`, and moves the + * zone every bound `Date` and every `DATETIME` is rendered in. The text is + * the narrower change, and it is how PostgreSQL already reads a day. + * + * A host that set `dateStrings` itself is left alone, as + * {@link withUtcSession} leaves an explicit `timezone`; so is a + * function-valued `connection`, which the host builds per acquire. + */ + static withMysqlCalendarDayAsText(knexConfig: Record): Record { + if (!SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(knexConfig))) return knexConfig; + + const conn = knexConfig.connection; + if (!conn || typeof conn !== 'object' || (conn as any).dateStrings !== undefined) return knexConfig; + return { + ...knexConfig, + connection: { ...(conn as object), dateStrings: [...SqlDriver.MYSQL_TEXT_TEMPORAL_TYPES] }, + }; + } + + /** + * Keep a Postgres `date` a calendar-day STRING, never a JS `Date` (#11389). + * + * This is the Postgres counterpart of {@link withUtcSession}, and it exists + * for the same reason: the value a driver materialises must not depend on + * which machine's clock the Node process happens to be running. + * + * ## The measurement + * + * `node-postgres` parses OID 1082 with `new Date(y, m - 1, d)` — **local** + * midnight. `SqlDriver#toDateOnly` reads a `Date` with UTC components (its + * documented contract, see there), so on a process east of UTC the two + * disagree by a calendar day. Measured on PostgreSQL 16, one stored row + * `d = '2026-08-24'`, only the process `TZ` changed: + * + * | process TZ | pg materialises | `toDateOnly` returned | + * |---|---|---| + * | `UTC` | `2026-08-24T00:00:00.000Z` | `2026-08-24` | + * | `America/New_York` | `2026-08-24T04:00:00.000Z` | `2026-08-24` | + * | `Asia/Shanghai` | `2026-08-23T16:00:00.000Z` | **`2026-08-23`** | + * + * West of UTC the UTC components happen to name the right day, which is why + * the `Temporal Conformance (live PG + MySQL)` job — pinned at + * `TZ=America/New_York` — was green throughout. Only an east-of-UTC process + * zone can fail it, so the pin that guards this sweeps the process zone. + * + * ## Why the parser and not the reader + * + * The alternative — read local components in `toDateOnly` — was measured and + * rejected: that helper is shared by the READ path (`formatOutput`, + * `presentReadValue`), the WRITE path (`formatInput`) and the FILTER path + * (`coerceFilterValue`), and the `Date`s they receive are on different + * clocks. A caller's `new Date('2026-08-24')` is UTC midnight, so under + * `TZ=America/New_York` local components read it as `2026-08-23` — the same + * one-day error in the mirror direction, moved onto the write and filter + * paths. Fixing it at the parser leaves exactly one clock in play, because + * the driver then never produces a `Date` for a `date` column at all — which + * is already how SQLite behaves (TEXT round-trip) and, via + * {@link withMysqlCalendarDayAsText}, how mysql2 behaves. (Before that, + * {@link withUtcSession}'s `timezone: 'Z'` kept mysql2's `Date` on the one + * UTC clock, but read a year below 100 a century late.) + * + * `pool.afterCreate` is the hook rather than a `pg.types.setTypeParser` + * call because `setTypeParser` mutates the pg-types registry **process + * wide**, which would reach every other pg client in the host application. + * A parser registered on the connection is scoped to the pools this driver + * opened; a host's own `pg` clients keep the stock behaviour. `pg` is an + * optional peer dependency and is never imported here — `setTypeParser` / + * `getTypeParser` are read off the `pg.Client` knex hands the hook. + * + * `timestamptz` / `timestamp` are deliberately untouched here: those are + * instants, and a `Date` is the right materialisation for them at the + * CLIENT layer, where the wire text is parsed. It is not, however, what the + * driver hands out of its read doors: [ADR-0053 D-F1/D-F2] (#13973) fold that + * `Date` to the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text in `formatOutput` + * / `presentReadValue`, so every dialect presents the instant the way + * SQLite always has. This comment used to add that "`Field.datetime` + * depends on" the `Date` materialisation; #13973 checked that on the tree + * and it did not hold — nothing on the read path consumed the `Date` (the + * SQLite-gated `formatOutput` never touched a datetime column on Postgres), + * and every in-repo reader of the value already accepted the text form. + * Canonicalising at the driver's own read boundary rather than at this + * parser is the ruled shape: one clock stays in play here for `date`, the + * instant types keep their stock parser, and no host `pg` client is touched. + * + * A host's existing `pool.afterCreate` is chained rather than replaced, + * exactly as in {@link withUtcSession}. + */ + static withPostgresCalendarDayAsText(knexConfig: Record): Record { + if (!SqlDriver.POSTGRES_WIRE_CLIENTS.has(String(knexConfig.client ?? ''))) return knexConfig; + + const out: Record = { ...knexConfig }; + const pool = (out.pool ?? {}) as Record; + const hostAfterCreate = pool.afterCreate as + | ((conn: unknown, done: (err?: unknown) => void) => void) + | undefined; + out.pool = { + ...pool, + afterCreate(connection: any, done: (err?: unknown, conn?: unknown) => void) { + const chain = (): void => { + if (!hostAfterCreate) return done(undefined, connection); + hostAfterCreate(connection, (hostErr?: unknown) => done(hostErr, connection)); + }; + // Not a `pg.Client` after all (a stub, a future knex shape): leave the + // connection exactly as it was rather than failing the acquire. + if ( + typeof connection?.setTypeParser !== 'function' || + typeof connection?.getTypeParser !== 'function' + ) { + return chain(); + } + const parseTextArray = connection.getTypeParser(SqlDriver.PG_OID_TEXT_ARRAY, 'text'); + // The wire form of `date` IS `YYYY-MM-DD`; handing it back verbatim is + // both the fix and the whole parser. + connection.setTypeParser(SqlDriver.PG_OID_DATE, (text: string) => text); + connection.setTypeParser(SqlDriver.PG_OID_DATE_ARRAY, parseTextArray); + chain(); + }, + }; + return out; + } + + /** + * Per-request SQL query timing (perf-tuning mode). Correlates knex's + * `query` → `query-response` / `query-error` events by `__knexQueryUid` and + * folds each query's wall time into the ambient request collector's `db` + * aggregate — one `Server-Timing` member carrying total DB time **and** a + * query count, the number most useful for spotting N sequential round-trips + * in DevTools → Network → Timing. + * + * Attribution is captured at `query` time, which runs inside the initiating + * request's `AsyncLocalStorage` scope (knex emits it synchronously from the + * runner after connection acquisition, still on the awaited call chain), so + * concurrent requests never cross-attribute. When perf-tuning is off, + * {@link currentPerfTiming} is `undefined` and the listener returns at once — + * nothing is tracked and the map stays empty, so the cost is a single ALS + * lookup per query. + * + * The aggregate records only durations and a count — never SQL text — so the + * `Server-Timing` header can be surfaced without leaking query shapes to + * non-admins. When an admin opts into DETAIL mode (`X-OS-Debug-Timing: json`, + * admin-gated), each query's PARAMETRIZED statement (knex's `q.sql`, which + * carries `?` placeholders — the bindings live separately and are NEVER + * recorded) is additionally captured so the admin-only detail payload can list + * the slowest queries by shape. Literal row values never enter the collector. + */ + static installQueryTiming(this: Pick): void { + // uid → { start, collector }, populated only while a collector is active, + // and always removed when the query settles (bounded by in-flight queries). + const inflight = new Map(); + this.knex.on('query', (q: any) => { + const timing = currentPerfTiming(); + if (!timing) return; + const uid = q?.__knexQueryUid; + if (typeof uid === 'string') inflight.set(uid, { t0: perfNow(), timing }); + }); + const settle = (q: any): void => { + const uid = q?.__knexQueryUid; + if (typeof uid !== 'string') return; + const rec = inflight.get(uid); + if (!rec) return; + inflight.delete(uid); + const dur = perfNow() - rec.t0; + rec.timing.count('db', dur, 'queries'); + // Admin-gated detail mode: keep the query SHAPE (parametrized SQL, no + // bindings) so the slowest queries can be surfaced. `recordDetail` is a + // no-op unless detail capture is on, so this is free on the normal path. + if (rec.timing.detailEnabled && typeof q?.sql === 'string') { + rec.timing.recordDetail('db', q.sql, dur); + } + }; + this.knex.on('query-response', (_response: any, q: any) => settle(q)); + this.knex.on('query-error', (_error: any, q: any) => settle(q)); + } + + /** The body of `SqlDriver#assertSchemaMutable`; the method's docblock there is its contract. */ + static assertSchemaMutable(this: Pick, operation: string): void { + if (this.schemaMode !== 'managed') { + throw new ExternalSchemaModeViolationError( + `DDL operation '${operation}' is forbidden: datasource schemaMode='${this.schemaMode}'. ` + + `ObjectStack never mutates the schema of an external database.`, + ); + } + this.assertDialectEmits(operation); + } + + /** The body of `SqlDriver#assertDialectEmits`; the method's docblock there is its contract. */ + static assertDialectEmits(this: Pick, operation: string): void { + const client = SqlDriver.clientSpelling(this.config); + if (!SqlDriver.POSTGRES_WIRE_ONLY_CLIENTS.has(client)) return; + throw new UnsupportedDialectEmissionError(client, operation, SqlDriver.emissionClients()); + } +} 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 f6ef00a3e13..f124964b669 100644 --- a/packages/drivers/driver-sql/src/sql-driver-column-creation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-column-creation.ts @@ -2,45 +2,283 @@ /** * [#29520 codemod] The "Column creation helper" section of `SqlDriver`: the - * bodies of `decorateHashShadowDuplicate`, `schemaBytesPerChar`, - * `rethrowWithRowSizeExplanation` moved out of `sql-driver.ts` whole by - * `scripts/codemod-split-sql-driver.mjs`. + * bodies of `declaredVarcharLength`, `keyableTextLength`, + * `hashShadowColumnFor`, `createHashShadowUniqueIndex`, + * `hashShadowColumnState`, `dropOrphanedHashShadowColumn`, + * `decorateHashShadowDuplicate`, `hashShadowSourceColumns`, + * `varcharPackLength`, `rowWidthProfile`, `schemaBytesPerChar`, + * `explainRowSizeOverflow`, `rethrowWithRowSizeExplanation`, `jsonColumn` moved + * out of `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link ColumnCreationFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 ColumnCreationHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `columnCreationHost` 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. + * `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 + * this module. */ -import type { SqlDriver } from './sql-driver.js'; +import { GLOBAL_TENANT, canonicalIndexKey, hashShadowColumnFor, parseHashShadowKeyParts } from './schema-drift.js'; +import type { HashShadowKeyPart } from './schema-drift.js'; +import type { Knex } from 'knex'; +import { AUDIT_TIMESTAMP_COLUMNS } from './sql-driver-top-level.js'; +import type { RowWidthContribution } from './sql-driver-top-level.js'; +import { SqlDriverStatics as SqlDriver } from './sql-driver.js'; +import type { SqlDriver as SqlDriverClass } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link ColumnCreationFacet} 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 ColumnCreationHost { - explainHashShadowDuplicate: SqlDriver['explainHashShadowDuplicate']; - explainRowSizeOverflow: SqlDriver['explainRowSizeOverflow']; - readonly knex: SqlDriver['knex']; - logDurabilityFailure: SqlDriver['logDurabilityFailure']; + explainHashShadowDuplicate: SqlDriverClass['explainHashShadowDuplicate']; + explainRowSizeOverflow: SqlDriverClass['explainRowSizeOverflow']; + hashShadowColumnState: SqlDriverClass['hashShadowColumnState']; + readonly isMysql: SqlDriverClass['isMysql']; + readonly isSqlite: SqlDriverClass['isSqlite']; + readonly knex: SqlDriverClass['knex']; + logDurabilityFailure: SqlDriverClass['logDurabilityFailure']; + readonly logger: SqlDriverClass['logger']; + rowWidthProfile: SqlDriverClass['rowWidthProfile']; + schemaBytesPerChar: SqlDriverClass['schemaBytesPerChar']; + varcharColumnChars: SqlDriverClass['varcharColumnChars']; } -/** The body of `SqlDriver#decorateHashShadowDuplicate`; the method's docblock there is its contract. */ -export async function decorateHashShadowDuplicate(this: Pick, tableName: string, values: Record, error: unknown): Promise { +/** The bodies of the "Column creation helper" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class ColumnCreationFacet { + /** The body of `SqlDriver#declaredVarcharLength`; the method's docblock there is its contract. */ + static declaredVarcharLength(field: any): number | null { + const declared = (field as { maxLength?: unknown }).maxLength; + const n = typeof declared === 'string' ? Number(declared) : declared; + if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0) { + return SqlDriver.DEFAULT_STRING_VARCHAR_CHARS; + } + return n > SqlDriver.MAX_VARCHAR_CHARS ? null : n; + } + + /** The body of `SqlDriver#keyableTextLength`; the method's docblock there is its contract. */ + static keyableTextLength(field: any): number | null { + const declared = (field as { maxLength?: unknown }).maxLength; + const n = typeof declared === 'string' ? Number(declared) : declared; + if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0) return null; + if (n > SqlDriver.MAX_KEYABLE_VARCHAR_CHARS) return null; + return n; + } + + /** 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 + // exactly the column the sync creates, and the two halves of that question + // lived in different modules — which is how a healthy shadow-carried + // UNIQUE came to be reported as destructive drift while the shadow column + // itself was protected as driver-owned. One derivation, both readers. + return hashShadowColumnFor(indexName); + } + + /** The body of `SqlDriver#createHashShadowUniqueIndex`; the method's docblock there is its contract. */ + static async createHashShadowUniqueIndex(this: Pick, tableName: string, indexName: string, columns: string[], nullSafeColumns?: ReadonlySet): Promise { + if (!this.isMysql) return false; + const shadow = SqlDriver.hashShadowColumnFor(indexName); + const ref = (c: string) => `\`${c.replace(/`/g, '``')}\``; + // The DECLARED key part: NULL-safe parts in their COALESCE form (commit df1c75c4b), + // plain parts as the bare column. + const part = (c: string) => + nullSafeColumns?.has(c) ? `COALESCE(${ref(c)}, '${GLOBAL_TENANT}')` : ref(c); + // ONE argument needs no separator, and CONCAT of one value would only add + // a chance to get the encoding wrong. + const expr = + columns.length === 1 + ? part(columns[0]!) + : `CONCAT(${columns.map(part).join(', 0x1f, ')})`; + const addColumn = + `ADD COLUMN ${ref(shadow)} VARBINARY(32) GENERATED ALWAYS AS (UNHEX(SHA2(${expr}, 256))) STORED`; + const addKey = `ADD UNIQUE KEY ${ref(indexName)} (${ref(shadow)})`; + // Commit cd1348802: the shadow column OUTLIVES the index it carries. Dropping a + // UNIQUE key by name does not drop the generated column keyed by it, so + // every path that drops and re-syncs — `recreate_index` above all — arrives + // back here with the survivor still on the table. The unconditional + // `ADD COLUMN` then failed with a duplicate-COLUMN error, which is matched + // by NEITHER the "already exists" absorb (that spells index names) nor the + // unique-violation branch, so the apply ended with the constraint DROPPED + // and not re-created: an operator following the differ's own advice removed + // a live uniqueness guarantee. The survivor is therefore inspected, never + // assumed absent. + const state = await this.hashShadowColumnState(tableName, indexName, columns, nullSafeColumns); + if (state === 'foreign') { + // A real, non-generated column already owns the name. It is not ours to + // drop — it may hold data — so this route is refused and the caller falls + // through to the refusal commit d0e3a885b named, which is the honest outcome. + this.logDurabilityFailure( + `[sql-driver] cannot carry UNIQUE index '${indexName}' on "${tableName}" on a hash shadow — the ` + + `column "${shadow}" already exists and is NOT a generated column, so it is not the driver's to ` + + `replace: it may hold data. The declared constraint is NOT enforced; rename or drop that column.`, + ); + return false; + } + if (state === 'reusable') { + // The survivor already hashes exactly the declared key — re-key it rather + // than rebuild the table for a column that is byte-for-byte what we want. + await this.knex.raw(`ALTER TABLE ${ref(tableName)} ${addKey}`); + } else if (state === 'stale') { + // The survivor hashes a DIFFERENT key than metadata now declares — the + // raw-column shadow from before commit df1c75c4b is exactly this case. Reusing it would + // re-enforce the old constraint under the new name, which is the silent + // wrong answer; the column carries no user data (it is derived), so it is + // dropped and re-generated. Two statements rather than one ALTER: the + // index it carried is already gone by the time we are here, and a single + // `DROP COLUMN c, ADD COLUMN c` is a shape not worth relying on. + await this.knex.raw(`ALTER TABLE ${ref(tableName)} DROP COLUMN ${ref(shadow)}`); + await this.knex.raw(`ALTER TABLE ${ref(tableName)} ${addColumn}, ${addKey}`); + } else { + await this.knex.raw(`ALTER TABLE ${ref(tableName)} ${addColumn}, ${addKey}`); + } + // The boot log describes the key the shadow actually enforces — the + // NULL-safe parts in their COALESCE spelling — so "carried" can be read + // literally (commit df1c75c4b). + const described = columns + .map((c) => (nullSafeColumns?.has(c) ? `COALESCE(${c}, '${GLOBAL_TENANT}')` : c)) + .join(', '); + this.logger.warn( + `[sql-driver] UNIQUE index '${indexName}' on "${tableName}" is carried by the hash-shadow column ` + + `"${shadow}" (SHA-256 of ${described}), because MySQL cannot key ${columns.length > 1 ? 'this column set' : 'a column'} ` + + `longer than ${SqlDriver.MAX_KEYABLE_VARCHAR_CHARS} characters directly. The declared ` + + `constraint is enforced over the full value; only the physical key differs.` + + (state === 'reusable' + ? ` The shadow column already existed and hashes this exact key — re-keyed in place.` + : state === 'stale' + ? ` A surviving shadow column hashed a DIFFERENT key and was re-generated.` + : ''), + { tableName, indexName, columns, shadow, shadowColumnState: state }, + ); + return true; + } + + /** The body of `SqlDriver#hashShadowColumnState`; the method's docblock there is its contract. */ + static async hashShadowColumnState(this: Pick, tableName: string, indexName: string, columns: string[], nullSafeColumns?: ReadonlySet): Promise<'absent' | 'reusable' | 'stale' | 'foreign'> { + let rows: Array>; + try { + rows = await this.knex + .select('GENERATION_EXPRESSION') + .from('information_schema.COLUMNS') + .where({ + TABLE_SCHEMA: this.knex.client.database(), + TABLE_NAME: tableName, + COLUMN_NAME: SqlDriver.hashShadowColumnFor(indexName), + }); + } catch { + return 'absent'; + } + if (rows.length === 0) return 'absent'; + const expr = String(rows[0]?.GENERATION_EXPRESSION ?? rows[0]?.generation_expression ?? ''); + if (expr.trim() === '') return 'foreign'; + const parts = parseHashShadowKeyParts(expr); + if (parts.length === 0) return 'stale'; + const carried = canonicalIndexKey( + parts.map((k) => k.column), + parts.filter((k) => k.nullSafe).map((k) => k.column), + ); + return carried === canonicalIndexKey(columns, [...(nullSafeColumns ?? [])]) + ? 'reusable' + : 'stale'; + } + + /** The body of `SqlDriver#dropOrphanedHashShadowColumn`; the method's docblock there is its contract. */ + static async dropOrphanedHashShadowColumn(this: Pick, tableName: string, indexName: string): Promise { + // Shadows are a MySQL-only construct: `createHashShadowUniqueIndex` refuses + // every other dialect, so there is nothing to collect and no catalog read + // worth paying for. + if (!this.isMysql) return false; + const shadow = SqlDriver.hashShadowColumnFor(indexName); + + let generationExpression: string; + try { + const rows = await this.knex + .select('GENERATION_EXPRESSION') + .from('information_schema.COLUMNS') + .where({ + TABLE_SCHEMA: this.knex.client.database(), + TABLE_NAME: tableName, + COLUMN_NAME: shadow, + }); + if (rows.length === 0) return false; // nothing survived; the ordinary case + generationExpression = String( + rows[0]?.GENERATION_EXPRESSION ?? rows[0]?.generation_expression ?? '', + ); + } catch { + return false; + } + if (generationExpression.trim() === '') { + this.logger.warn( + `[sql-driver] NOT dropping "${tableName}"."${shadow}" while collecting the retired index ` + + `'${indexName}' — a column of that name exists and is NOT a generated column, so it is not ` + + `the driver's to drop and may hold data. Remove it by hand if it is unwanted.`, + { tableName, indexName, shadow }, + ); + return false; + } + + let stillKeyedBy: string[]; + try { + const rows = await this.knex + .select('INDEX_NAME') + .from('information_schema.STATISTICS') + .where({ + TABLE_SCHEMA: this.knex.client.database(), + TABLE_NAME: tableName, + COLUMN_NAME: shadow, + }); + stillKeyedBy = [ + ...new Set(rows.map((r: any) => String(r.INDEX_NAME ?? r.index_name ?? ''))), + ].filter((n) => n !== ''); + } catch { + return false; + } + if (stillKeyedBy.length > 0) { + this.logger.warn( + `[sql-driver] NOT dropping "${tableName}"."${shadow}" while collecting the retired index ` + + `'${indexName}' — ${stillKeyedBy.length} index(es) still key that column ` + + `(${stillKeyedBy.join(', ')}), so dropping it would remove them too.`, + { tableName, indexName, shadow, stillKeyedBy }, + ); + return false; + } + + await this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [tableName, shadow]); + this.logger.warn( + `[sql-driver] dropped the hash-shadow column "${shadow}" from "${tableName}" — the UNIQUE index ` + + `'${indexName}' it carried is retired, so the STORED generated column was recomputed on every ` + + `write for a constraint that no longer exists.`, + { tableName, indexName, shadow }, + ); + return true; + } + + /** 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; try { explanation = await this.explainHashShadowDuplicate(tableName, values, error); @@ -57,8 +295,53 @@ export async function decorateHashShadowDuplicate(this: Pick): Promise<{ bytesPerChar: number; charset: string } | null> { + /** The body of `SqlDriver#hashShadowSourceColumns`; the method's docblock there is its contract. */ + static async hashShadowSourceColumns(this: Pick, tableName: string, indexName: string): Promise { + try { + const rows: Array<{ GENERATION_EXPRESSION?: string; generation_expression?: string }> = + await this.knex + .select('GENERATION_EXPRESSION') + .from('information_schema.COLUMNS') + .where({ + TABLE_SCHEMA: this.knex.client.database(), + TABLE_NAME: tableName, + COLUMN_NAME: SqlDriver.hashShadowColumnFor(indexName), + }); + const expr = String(rows[0]?.GENERATION_EXPRESSION ?? rows[0]?.generation_expression ?? ''); + // `unhex(sha2(`a`,256))`, `unhex(sha2(concat(`a`,0x1f,`b`),256))`, or with + // a NULL-safe part: `…concat(coalesce(`org`,_utf8mb4'__global__'),0x1f,`b`)…` + // (commit df1c75c4b). Commit cd1348802 moved the parse itself next to the shadow vocabulary + // in `schema-drift.ts`: the differ reads the same expression to decide + // whether a shadow-carried index enforces what metadata declares, and two + // copies of this regex would be two answers to one question. + return parseHashShadowKeyParts(expr); + } catch { + return []; + } + } + + /** The body of `SqlDriver#varcharPackLength`; the method's docblock there is its contract. */ + static varcharPackLength(chars: number, bytesPerChar: number): number { + const payload = chars * bytesPerChar; + return payload + (payload < 256 ? 1 : 2); + } + + /** The body of `SqlDriver#rowWidthProfile`; the method's docblock there is its contract. */ + static rowWidthProfile(this: Pick, fields: Record | undefined, keyedColumns: ReadonlyMap, bytesPerChar: number): { columns: RowWidthContribution[]; totalBytes: number } { + const builtin = new Set(['id', ...AUDIT_TIMESTAMP_COLUMNS]); + const columns: RowWidthContribution[] = []; + for (const [name, field] of Object.entries(fields ?? {})) { + if (builtin.has(name)) continue; + const chars = this.varcharColumnChars(field, keyedColumns.get(name)); + if (chars === null) continue; + columns.push({ name, chars, bytes: SqlDriver.varcharPackLength(chars, bytesPerChar) }); + } + columns.sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name)); + return { columns, totalBytes: columns.reduce((sum, c) => sum + c.bytes, 0) }; + } + + /** The body of `SqlDriver#schemaBytesPerChar`; the method's docblock there is its contract. */ + static async schemaBytesPerChar(this: Pick): Promise<{ bytesPerChar: number; charset: string } | null> { try { const res: any = await this.knex.raw( 'select cs.CHARACTER_SET_NAME as charset, cs.MAXLEN as maxlen ' + @@ -76,8 +359,72 @@ export async function schemaBytesPerChar(this: Pick) } } -/** The body of `SqlDriver#rethrowWithRowSizeExplanation`; the method's docblock there is its contract. */ -export async function rethrowWithRowSizeExplanation(this: Pick, tableName: string, fields: Record | undefined, keyedColumns: ReadonlyMap, addedColumns: string[] | null, e: any): Promise { + /** The body of `SqlDriver#explainRowSizeOverflow`; the method's docblock there is its contract. */ + static async explainRowSizeOverflow(this: Pick, tableName: string, fields: Record | undefined, keyedColumns: ReadonlyMap, addedColumns: string[] | null, cause: unknown): Promise { + if ((cause as { code?: string } | undefined)?.code !== 'ER_TOO_BIG_ROWSIZE') return null; + + const server = await this.schemaBytesPerChar(); + const bytesPerChar = server?.bytesPerChar ?? SqlDriver.ASSUMED_BYTES_PER_CHAR; + const charsetNote = server + ? `${bytesPerChar} bytes/character (${server.charset}, this schema's charset)` + : `${bytesPerChar} bytes/character (utf8mb4 assumed — the server's charset could not be read)`; + const { columns, totalBytes } = this.rowWidthProfile(fields, keyedColumns, bytesPerChar); + + // Two DIFFERENT limits answer with this one code, and they quote different + // numbers. Reading the server's own text keeps the message from asserting + // the 65535 one over a refusal that was really InnoDB's page limit. + const serverText = String((cause as { sqlMessage?: string; message?: string }).sqlMessage ?? ''); + const pageLimit = /row size too large \(>\s*(\d+)\)/i.exec(serverText)?.[1] ?? null; + const limitClause = pageLimit + ? `InnoDB's per-PAGE limit of ${pageLimit} bytes — which many NARROW bounded columns reach far below the ` + + `${SqlDriver.MYSQL_ROW_BYTE_BUDGET}-byte one, because a column narrower than about 256 bytes is never ` + + `stored off-page (measured on 8.0.46: 40 fields at \`maxLength: 63\` are refused)` + : `the server's ${SqlDriver.MYSQL_ROW_BYTE_BUDGET}-byte budget for one ROW`; + + const SHOWN = 8; + const listed = columns + .slice(0, SHOWN) + .map((c) => `"${c.name}" varchar(${c.chars}) = ${c.bytes} bytes`) + .join(', '); + const rest = columns.length > SHOWN ? `, and ${columns.length - SHOWN} more` : ''; + const inventory = + columns.length === 0 + ? `This object produces no varchar column at all, so the width is in the built-in columns or in a ` + + `type this diagnostic does not size — report it, because that is not a shape it was measured on.` + : `Its ${columns.length} varchar column(s) take ${totalBytes} bytes at ${charsetNote}, on top of the ` + + `built-in \`id\` (a varchar(${SqlDriver.DEFAULT_STRING_VARCHAR_CHARS})), \`created_at\` and ` + + `\`updated_at\` columns. Widest first: ${listed}${rest}.`; + + const opening = + addedColumns && addedColumns.length > 0 + ? `[sql-driver] cannot add column(s) ${addedColumns.map((c) => `"${c}"`).join(', ')} to "${tableName}"` + : `[sql-driver] cannot create table "${tableName}"`; + + return ( + `${opening} — MySQL refuses the whole row: every bounded column's DECLARED width is charged against ` + + `${limitClause}, whether or not a row ever holds that much. ${inventory} ` + + `Fix it on the declarations: lower \`maxLength\` on the widest field(s), or drop the bound from a ` + + `text-family field no index keys on so it is emitted as TEXT — an off-page TEXT column costs this ` + + `budget a small pointer rather than its full declared width. Note that a field declaring NO ` + + `\`maxLength\` still takes varchar(${SqlDriver.DEFAULT_STRING_VARCHAR_CHARS}) ` + + `(${SqlDriver.varcharPackLength(SqlDriver.DEFAULT_STRING_VARCHAR_CHARS, bytesPerChar)} bytes here), and ` + + `so do \`lookup\`, \`user\`, \`auto_number\` and the option types — an object can reach this limit ` + + `without declaring a bound anywhere. The server's own error names no column, which is the whole reason ` + + `this one does.` + ); + } + + /** + * Re-throw a schema-mutation failure carrying {@link explainRowSizeOverflow}'s + * explanation, or the original untouched when it is not a row-size refusal. + * + * The SAME failure, re-worded — the boot still fails, loudly, and no object is + * registered that MySQL declined to build. `sqlMessage` rather than `message` + * for the quoted server text: knex prefixes `message` with the entire DDL + * statement, which for the object that trips this is thousands of characters + * of `varchar(...)` and buries the one sentence the server actually said. + */ + static async rethrowWithRowSizeExplanation(this: Pick, tableName: string, fields: Record | undefined, keyedColumns: ReadonlyMap, addedColumns: string[] | null, e: any): Promise { const overflow = await this.explainRowSizeOverflow(tableName, fields, keyedColumns, addedColumns, e); if (!overflow) throw e; const said = String(e?.sqlMessage ?? e?.message ?? e); @@ -87,3 +434,9 @@ export async function rethrowWithRowSizeExplanation(this: Pick, table: Knex.CreateTableBuilder, name: string): any { + return this.isSqlite ? table.text(name) : table.json(name); + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-crud.ts b/packages/drivers/driver-sql/src/sql-driver-crud.ts index 579e45b4317..5af49ca465d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-crud.ts +++ b/packages/drivers/driver-sql/src/sql-driver-crud.ts @@ -2,63 +2,254 @@ /** * [#29520 codemod] The "CRUD — IDataDriver core" section of `SqlDriver`: the - * bodies of `create`, `readBackInsertedRows`, `sequenceKeyHash` moved out of - * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * bodies of `find`, `findRows`, `findOne`, `create`, `readBackInsertedRows`, + * `ensureSequencesTable`, `sequenceKeyHash` moved out of `sql-driver.ts` whole + * by `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link CrudFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 CrudHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * The bodies reach the class through {@link CrudHost}: exactly the `SqlDriver` + * members they read, private ones included. Every stub hands its body, by a + * `.call`, the same host: `crudHost` 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. + * 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 { DriverQuery } from '@objectstack/spec/contracts'; +import type { Knex } from 'knex'; import { nanoid } from 'nanoid'; import { createHash } from 'node:crypto'; import { - AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, insertedRowsNotReadBackError, + AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, SEQUENCES_TABLE, insertedRowsNotReadBackError, + isUnresolvableColumnError, } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link CrudFacet} 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 CrudHost { + applyFilters: SqlDriver['applyFilters']; applyTenantScope: SqlDriver['applyTenantScope']; applyWriteColumnMap: SqlDriver['applyWriteColumnMap']; + assertBareKnexSafe: SqlDriver['assertBareKnexSafe']; auditMissingTenant: SqlDriver['auditMissingTenant']; + backendStatementFault: SqlDriver['backendStatementFault']; collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations']; + createSequencesTable: SqlDriver['createSequencesTable']; decorateHashShadowDuplicate: SqlDriver['decorateHashShadowDuplicate']; + ensureSequencesKeyHashShape: SqlDriver['ensureSequencesKeyHashShape']; fillAutoNumberFields: SqlDriver['fillAutoNumberFields']; + findRows: SqlDriver['findRows']; formatInput: SqlDriver['formatInput']; formatOutput: SqlDriver['formatOutput']; getBuilder: SqlDriver['getBuilder']; injectTenantOnInsert: SqlDriver['injectTenantOnInsert']; readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows']; + readonly isSqlite: SqlDriver['isSqlite']; + readonly knex: SqlDriver['knex']; + mapSortField: SqlDriver['mapSortField']; + orderKeysFor: SqlDriver['orderKeysFor']; readBackInsertedRows: SqlDriver['readBackInsertedRows']; remoteColumn: SqlDriver['remoteColumn']; resolveTenantField: SqlDriver['resolveTenantField']; resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax']; rotationWriteTarget: SqlDriver['rotationWriteTarget']; + sequencesHasKeyHash: SqlDriver['sequencesHasKeyHash']; + sequencesTableEnsurePromise: SqlDriver['sequencesTableEnsurePromise']; + sequencesTableReady: SqlDriver['sequencesTableReady']; stampInsertTimestamps: SqlDriver['stampInsertTimestamps']; + unresolvableFilterColumnRefusal: SqlDriver['unresolvableFilterColumnRefusal']; } -/** The body of `SqlDriver#create`; the method's docblock there is its contract. */ -export async function create(this: Pick, object: string, data: Record, options?: DriverOptions): Promise> { +/** The bodies of the "CRUD — IDataDriver core" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class CrudFacet { + /** The body of `SqlDriver#find`; the method's docblock there is its contract. */ + static async find(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { + return this.findRows(object, query, options); + } + + /** + * The body of {@link find}, shared with {@link findOne}. + * + * `singleRowLookup` marks the caller as `findOne` rather than a page, and the + * only thing it changes is that no ordering is imposed on an unsorted read + * (objectstack#4363). `findOne` reaches here as `limit: 1`, which is + * indistinguishable from "page one of a walk with page size 1" — and the two + * want opposite things: + * + * - The paged read needs a total order, or its pages are not a partition. + * - `findOne` needs the plan its predicate earned. `ORDER BY id LIMIT 1` is + * the shape that makes a planner abandon the predicate's own index and walk + * the primary key applying a filter instead, because at `LIMIT 1` that + * looks like it will stop early. Measured on Postgres 16 over 2M rows, + * `WHERE owner_id = ? LIMIT 1` went 0.08 ms → 7.8 ms (~100×) and swapped + * `ticket_owner_idx` for `ticket_pkey`; at `LIMIT 5` and up the planner + * keeps the right index and pays only a top-N sort (~2 ms). Nothing is + * bought for that: `findOne` promises *a* matching record, never a position + * in a sequence, so there is no partition to preserve. + * + * `MongoDBDriver.findOne` carries the same flag into its own `buildSortSpec`, + * so both drivers now read a `findOne` the same way: honour the caller's + * `orderBy`, impose nothing when there is none. (Mongo used to translate + * `where` and drop `orderBy` outright — an earlier version of this comment + * cited that as agreement, which it was not; objectstack#4419.) The + * obligation the contract states is on `find`, and this keeps it there. + */ + static async findRows(this: Pick, object: string, query: DriverQuery, options?: DriverOptions, singleRowLookup = false): Promise { + // The whole ORDER BY, decided once: the caller's keys plus whatever this + // driver has to add to make a paged read deterministic. Derived out here + // rather than inside `buildBase` so the recovery ladder below can ask + // whether the statement HAS a sort to drop, instead of asking whether the + // caller supplied one — since #4363 those are different questions. + const orderKeys = this.orderKeysFor(object, query, { singleRowLookup }); + + // Build everything EXCEPT the SELECT list, so the unknown-column retry + // below can rebuild without re-deriving where/order/pagination. + const buildBase = (opts?: { withOrderBy?: boolean }) => { + const withOrderBy = opts?.withOrderBy !== false; + const b = this.getBuilder(object, options); + this.applyTenantScope(b, object, options); + + // WHERE + if (query.where) { + this.applyFilters(b, query.where, options); + } + + // ORDER BY + if (withOrderBy) { + for (const key of orderKeys) { + b.orderBy(this.remoteColumn(object, key.field, this.mapSortField(key.field)), key.direction); + } + } + + // PAGINATION + if (query.offset !== undefined) b.offset(query.offset); + if (query.limit !== undefined) b.limit(query.limit); + + return b; + }; + + const builder = buildBase(); + + // SELECT + if (Array.isArray(query.fields) && query.fields.length > 0) { + builder.select(query.fields.map((f) => this.mapSortField(f))); + } else { + builder.select('*'); + } + + let results: any[]; + try { + results = await builder; + } catch (error: any) { + if (isUnresolvableColumnError(error)) { + // A `$select` projection naming a column the table lacks (e.g. a + // generic list view auto-requesting `status`/`due_date`/`image` on an + // object without them) makes the WHOLE query fail. Swallowing that + // into an empty result — the old behavior — reads to the UI as "no + // records exist" even though rows are there: a silent data-loss + // footgun. When the failure came from the projection, retry once + // selecting all columns so the real rows still come back; the unknown + // field is simply absent from each row (it never existed). The + // engine's unknown-field filter is the first line of defense, but it + // only fires when the object's schema is populated in the registry — + // this driver backstop holds even when it isn't (notably the cloud + // multi-tenant runtime, where the projection otherwise zeroes the list). + // + // An unknown ORDER BY column is the same footgun one clause over + // (objectstack#3821): a client that sorts by a field the table lacks + // used to get an empty page with a non-zero `total` — "the rows are + // there but none are shown". Rows matter more than their order, so + // drop the sort and return them unordered rather than nothing. Ladder: + // projection first (it is the likelier culprit and the cheaper thing + // to lose), then the sort, then give up. + // + // [#8790, maintainer ruling 2026-08-15] Where the ladder STOPS is the + // other half of the same argument. Every rung is built from + // `buildBase()`, which always re-applies `query.where` — so the ladder + // can drop a projection and can drop a sort, but it can never drop the + // clause that failed when the unresolvable column is in the WHERE. + // Both rungs then raise the same error and the method used to fall to + // `return []`. That terminal is now a refusal: rows matter more than + // their order, but nothing matters more than the predicate, because + // answering without it returns records the caller excluded. See + // {@link unresolvableFilterColumnError}. The two recoveries above are + // deliberately untouched — the ruling narrows the terminal only. + const retries: Array<() => any> = []; + if (query.fields) retries.push(() => buildBase().select('*')); + if (orderKeys.length > 0) { + retries.push(() => buildBase({ withOrderBy: false }).select('*')); + } + results = []; + let recovered = false; + // The error to NAME the column from. The last rung is the one that has + // dropped everything droppable, so a column still unresolved there is + // the caller's WHERE — naming it from the ORIGINAL error would name the + // projection column on a query whose projection the ladder just fixed. + let lastError: unknown = error; + for (const retry of retries) { + try { + results = await retry(); + recovered = true; + break; + } catch (retryError) { + // Try the next, broader fallback. + lastError = retryError; + } + } + if (!recovered) throw this.unresolvableFilterColumnRefusal(object, lastError, query.where); + } else { + // [#8931] The terminal catch-all — see + // {@link SqlDriver.backendStatementFault}. This is the `find` half of + // the exit `count` spells below; both halves must answer one condition + // one way, which is the invariant #8790 established on this path and + // this ruling extends to every condition the ladder does not recover. + throw this.backendStatementFault(object, error); + } + } + + if (!Array.isArray(results)) { + return []; + } + + // formatOutput is dialect-agnostic for `Field.date` (ADR-0053 Phase 1); + // its json/boolean deserialisation stays SQLite-gated internally. Run it + // for every dialect so reads match `findOne` and date columns come back + // as `YYYY-MM-DD`. + for (const row of results) { + this.formatOutput(object, row); + } + return results; + } + + /** The body of `SqlDriver#findOne`; the method's docblock there is its contract. */ + static async findOne(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise | null> { + if (!query || typeof query !== 'object') return null; + const results = await this.findRows(object, { ...query, limit: 1 }, options, true); + return results[0] || null; + } + + /** The body of `SqlDriver#create`; the method's docblock there is its contract. */ + static async create(this: Pick, object: string, data: Record, options?: DriverOptions): Promise> { const { _id, ...rest } = data; const toInsert = { ...rest }; @@ -143,8 +334,60 @@ export async function create(this: Pick, object: string, writeTable: string, written: Record[], options?: DriverOptions): Promise[]> { + /** + * [#21227] Read back the rows an INSERT on this call just wrote, by the ids + * it wrote: what `create` and `bulkCreate` answer on a dialect whose INSERT + * answers no rows ({@link insertReturnsStoredRows}). + * + * One row per written row, in the written order (`IN (…)` promises no + * order), each through `formatOutput()`, the same presentation + * `returning('*')` gets on the other dialects and `update`'s own read-back + * gets on every dialect. + * + * # The read key: the written id, which this driver always holds + * + * Every row reaches the INSERT carrying the id this driver gave it: the + * caller's `id`, else its `_id`, else a nanoid minted in `create` / + * `bulkCreate` before the statement is built. The id is never asked of the + * database, so no insert id is read: the managed `id` column is a + * `varchar(255)` PRIMARY KEY with no AUTO_INCREMENT, and the insert id + * MySQL reports for it is `0`. The column is resolved through + * {@link remoteColumn}, so an external object whose `columnMap` renames + * `id` is read by its physical column, as its INSERT was written. + * + * # The table: the write target + * + * The table the statement wrote, a rotation shard included, so the read + * looks where the row landed rather than at the base name. + * + * # Tenant scope: the tenants the rows were WRITTEN under + * + * Routed through {@link applyTenantScope} like every read door in this class + * (`check:tenant-chokepoint`), scoped as `upsert`'s identity probe + * (`assertMergeLandedOnSuppliedIdentity`) scopes its own read: to the tenant + * each row was written under. On an ordinary tenanted call that is the + * caller's org, which `injectTenantOnInsert` stamped. On an admin write that + * names a tenant in the row data (a documented authority: explicit values + * are never overwritten), a read scoped to the caller's ACTIVE org would miss + * a row that really landed. A batch may name several tenants, so the scope is + * their union through `tenantIds`, which `applyTenantScope` already compiles + * as `IN (…)`; the caller's own `tenantIds` membership set is replaced, not + * widened. A row written with no tenant is no tenant's, and no tenant scope + * reaches it (ADR-0131 D1), so the rows' union scopes the read only when + * every row carries one. Otherwise the call's own options decide, and a call + * with no tenant leaves a row unstamped only because it carries none: + * `injectTenantOnInsert` fills every absent column on a call that does. With + * no tenant field, or no tenant on the call, `applyTenantScope` leaves the + * read unscoped by its own contract. The ids are this call's own and `id` is + * the PRIMARY KEY, so the read cannot answer a row this call did not write, + * in any organization. + * + * # A row that is not there + * + * Refused with {@link insertedRowsNotReadBackError}, never answered with the + * payload: see that function for why. + */ + static async readBackInsertedRows(this: Pick, object: string, writeTable: string, written: Record[], options?: DriverOptions): Promise[]> { if (written.length === 0) return []; const idColumn = this.remoteColumn(object, 'id', 'id'); const tenantField = this.resolveTenantField(object); @@ -177,9 +420,72 @@ export async function readBackInsertedRows(this: Pick this.formatOutput(object, byId.get(String(row.id)))); } -/** The body of `SqlDriver#sequenceKeyHash`; the method's docblock there is its contract. */ -export function sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string { + /** The body of `SqlDriver#ensureSequencesTable`; the method's docblock there is its contract. */ + static async ensureSequencesTable(this: Pick, parentTrx?: Knex.Transaction): Promise { + if (this.sequencesTableReady) return; + if (this.sequencesTableEnsurePromise) { + await this.sequencesTableEnsurePromise; + return; + } + // Which connection runs the DDL below. Normally a fresh pooled connection + // (`this.knex`), because `initObjects` pre-creates the table outside any data + // transaction. This lazy path is the fallback (e.g. an external object, or a + // consumer that writes without `initObjects`). If we are already inside the + // caller's transaction AND the pool can only ever hand out one connection + // (SQLite, pool max=1), that connection is busy with the open transaction — + // a bare `this.knex` here would block forever acquiring a second one and then + // fail with a Knex acquire-timeout (the reported batch/autonumber deadlock). + // Run the DDL on the caller's own transaction instead; SQLite permits DDL + // inside a transaction. We deliberately do NOT route DDL through `parentTrx` + // on MySQL, where DDL implicitly commits the caller's transaction; there the + // pool has room (max=5) for a fresh connection to create the table safely. + // The 5 is `buildSqlPool`'s — service-datasource's + // `default-datasource-driver-factory.ts` gives every datasource it composes + // `{min:0,max:5}` unless one declares its own `pool` — not knex's roomier + // `poolDefaults()` of `{min:2,max:10}`, which the factory never reaches. + const runner: Knex | Knex.Transaction = parentTrx && this.isSqlite ? parentTrx : this.knex; + // If we are about to run DDL on a fresh pooled connection while a SQLite + // transaction holds the only one, fail fast with a clear message instead of + // dead-locking. This catches the "caller opened a transaction but did not + // thread it through as parentTrx" regression at the call site (dev/test only). + if (runner === this.knex) this.assertBareKnexSafe('ensureSequencesTable'); + this.sequencesTableEnsurePromise = (async () => { + const exists = await runner.schema.hasTable(SEQUENCES_TABLE); + if (!exists) { + try { + await this.createSequencesTable(SEQUENCES_TABLE, runner); + this.sequencesHasKeyHash = true; + } catch (err: any) { + // Race or cross-process create — re-check existence; ignore + // "already exists" errors from any dialect. + const stillMissing = !(await runner.schema.hasTable(SEQUENCES_TABLE)); + if (stillMissing) throw err; + // A racing creator may have used an older schema. Migrate in place. + await this.ensureSequencesKeyHashShape(runner); + } + } else { + // Pre-existing table may predate the `key_hash`/`scope` shape. Migrate. + await this.ensureSequencesKeyHashShape(runner); + } + // Cache "ready" only when the DDL ran on a durable connection. If it rode + // the caller's transaction (the SQLite in-tx fallback above), the table is + // commit-conditional — a rollback would drop it — so leave the flag unset + // and re-verify (a cheap `hasTable`) on the next write rather than trusting + // a stale process-level flag. `initObjects` sets it durably up front, so + // the hot path is unaffected. + if (runner === this.knex) this.sequencesTableReady = true; + })(); + try { + await this.sequencesTableEnsurePromise; + } finally { + this.sequencesTableEnsurePromise = null; + } + } + + /** The body of `SqlDriver#sequenceKeyHash`; the method's docblock there is its contract. */ + static sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string { return createHash('sha256') .update(`${object}\u001f${tenantId}\u001f${field}\u001f${scope}`) .digest('hex'); } +} 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 45180df3dcd..b880dfa3117 100644 --- a/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-database-helpers.ts @@ -2,43 +2,67 @@ /** * [#29520 codemod] The "Database helpers" section of `SqlDriver`: the bodies of - * `sqliteFilename` moved out of `sql-driver.ts` whole by - * `scripts/codemod-split-sql-driver.mjs`. + * `sqliteFilename`, `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. Each function below is the body of the `SqlDriver` + * 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` * method of the same name exactly as it stood: not re-indented, not - * formatted, not edited. The method stays on the class with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 DatabaseHelpersHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `databaseHelpersHost` 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. + * 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 + * this module. */ +import { FILE_REFERENCE_TYPES } from '@objectstack/spec/data'; +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'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link DatabaseHelpersFacet} 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 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']; } -/** The body of `SqlDriver#sqliteFilename`; the method's docblock there is its contract. */ -export function sqliteFilename(this: Pick): string | null { +/** The bodies of the "Database helpers" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class DatabaseHelpersFacet { + /** The body of `SqlDriver#sqliteFilename`; the method's docblock there is its contract. */ + static sqliteFilename(this: Pick): string | null { if (!this.isSqlite) return null; // Redirected to `:memory:` because the declared file did not exist and the // host asked not to create one (#6743). `this.config` still names the @@ -53,3 +77,82 @@ export function sqliteFilename(this: Pick, resolve: () => boolean | Promise): boolean { + if (this.fileColumnsMovedDeclared) return false; + if (this.fileColumnsMovedAsked) return false; + this.fileColumnsMovedResolver = resolve; + return true; + } + + /** The body of `SqlDriver#setTenantKeyCertifiedResolver`; the method's docblock there is its contract. */ + static setTenantKeyCertifiedResolver(this: Pick, resolve: () => boolean | Promise): void { + this.tenantKeyCertifiedResolver = resolve; + this.tenantKeyCertified = undefined; + } + + /** The body of `SqlDriver#isTenantKeyCertified`; the method's docblock there is its contract. */ + static async isTenantKeyCertified(this: Pick): Promise { + if (this.tenantKeyCertified) return true; + const resolver = this.tenantKeyCertifiedResolver; + if (!resolver) return false; + try { + // Asked inside the pre-DDL scope: a store whose ledger does not exist + // yet answers with a refused read, which is not a fault (see + // {@link PRE_DDL_QUESTION_SCOPE}). + if ((await PRE_DDL_QUESTION_SCOPE.run(true, resolver)) !== true) return false; + } catch (e: any) { + // Not knowing reads as "not certified": the forms stay distinct and an + // 18.0 index is reported as a destructive recreate_index, which nothing + // applies unattended (safe-only boot reconcile, the artifact gate, + // `--allow-destructive`). Said once, so that report is not taken for a + // verdict on a certified store. + if (!this.tenantKeyCertifiedReadFailureReported) { + this.tenantKeyCertifiedReadFailureReported = true; + this.logger.warn( + `[schema-drift] could not read whether this database is certified for the organization-ownership shape ` + + `(${e?.message ?? e}); an index keyed COALESCE(, …) on a nullable tenant column is compared ` + + `as a different constraint from the plain key until it can be read.`, + ); + } + return false; + } + this.tenantKeyCertified = true; + return true; + } + + /** The body of `SqlDriver#resolveFileColumnsMoved`; the method's docblock there is its contract. */ + static async resolveFileColumnsMoved(this: Pick): Promise { + const resolver = this.fileColumnsMovedResolver; + if (!resolver) { + // A driver with nothing to ask has still settled its arm: `false`, the + // JSON encoding, which is what it will keep for the rest of its life. + // Recorded so a resolver supplied AFTER the first `initObjects` is + // refused rather than silently changing an already-frozen answer. + this.fileColumnsMovedAsked = true; + return; + } + this.fileColumnsMovedAsked = true; + this.fileColumnsMovedResolver = undefined; + try { + // [#20768] Asked inside the pre-DDL scope. On a new database the ledger + // the resolver reads does not exist yet, and that refusal is not a + // fault. The answer is unchanged — see {@link PRE_DDL_QUESTION_SCOPE}. + this.fileColumnsMoved = (await PRE_DDL_QUESTION_SCOPE.run(true, resolver)) === true; + } catch { + this.fileColumnsMoved = false; + } + } + + /** The body of `SqlDriver#mediaColumnIsJson`; the method's docblock there is its contract. */ + static mediaColumnIsJson(this: Pick): boolean { + return !this.fileColumnsMoved; + } + + /** The body of `SqlDriver#isJsonField`; the method's docblock there is its contract. */ + static isJsonField(this: Pick, type: string, field: any): boolean { + if (!isMultiValuedColumn(type, field) && FILE_REFERENCE_TYPES.has(type)) return this.mediaColumnIsJson(); + return JSON_COLUMN_TYPES.has(type) || isMultiValuedColumn(type, field); + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-deferred-ddl.ts b/packages/drivers/driver-sql/src/sql-driver-deferred-ddl.ts new file mode 100644 index 00000000000..27eb50daf34 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-deferred-ddl.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The "Deferred schema DDL (#3917)" section of `SqlDriver`: + * the bodies of `setDeferredDdl`, `getSchemaSyncStats`, + * `flushDeferredSchemaDdl` moved out of `sql-driver.ts` whole by + * `scripts/codemod-split-sql-driver.mjs`. + * + * A MOVE, not a redesign. {@link DeferredDdlFacet} 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 DeferredDdlHost}: exactly the + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `deferredDdlHost` 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 { PendingSchemaWork } from './schema-drift.js'; +import type { SqlDriver } from './sql-driver.js'; + +/** + * The `SqlDriver` members the bodies of {@link DeferredDdlFacet} 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 DeferredDdlHost { + deferredDdl: SqlDriver['deferredDdl']; + readonly deferredSchemaObjects: SqlDriver['deferredSchemaObjects']; + flushingDeferredDdl: SqlDriver['flushingDeferredDdl']; + initObjects: SqlDriver['initObjects']; + previewDeferredSchemaWork: SqlDriver['previewDeferredSchemaWork']; + readonly tablesCreatedHere: SqlDriver['tablesCreatedHere']; + readonly tablesFoundExisting: SqlDriver['tablesFoundExisting']; +} + +/** The bodies of the "Deferred schema DDL (#3917)" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class DeferredDdlFacet { + /** The body of `SqlDriver#setDeferredDdl`; the method's docblock there is its contract. */ + static setDeferredDdl(this: Pick, deferred: boolean): void { + this.deferredDdl = deferred; + } + + /** The body of `SqlDriver#getSchemaSyncStats`; the method's docblock there is its contract. */ + static getSchemaSyncStats(this: Pick): { created: number; existing: number } { + return { created: this.tablesCreatedHere.size, existing: this.tablesFoundExisting.size }; + } + + /** The body of `SqlDriver#flushDeferredSchemaDdl`; the method's docblock there is its contract. */ + static async flushDeferredSchemaDdl(this: Pick): Promise { + const pending = [...this.deferredSchemaObjects.values()]; + if (pending.length === 0) { + this.deferredDdl = false; + return []; + } + const performed = await this.previewDeferredSchemaWork(); + this.deferredSchemaObjects.clear(); + this.deferredDdl = false; + // Re-entering initObjects re-registers the same metadata (idempotent) and + // this time takes the DDL path, so create/alter/index/rotation handling + // stays in exactly one place. + // + // #9354: armed across that re-entry, and cleared in a `finally` so a refusal + // thrown out of the widening cannot leave the flag set on a driver the + // caller goes on using (`os migrate apply` keeps the stack alive to shut it + // down). It is the only thing distinguishing this path from boot sync. + this.flushingDeferredDdl = true; + try { + await this.initObjects(pending); + } finally { + this.flushingDeferredDdl = false; + } + return performed; + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-distinct.ts b/packages/drivers/driver-sql/src/sql-driver-distinct.ts index 598b83e21cb..ac5318634c5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-distinct.ts +++ b/packages/drivers/driver-sql/src/sql-driver-distinct.ts @@ -5,32 +5,38 @@ * `distinct` moved out of `sql-driver.ts` whole by * `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link DistinctFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 DistinctHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `distinctHost` 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. + * 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, FilterCondition } from '@objectstack/spec/data'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link DistinctFacet} 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 DistinctHost { applyFilters: SqlDriver['applyFilters']; @@ -42,8 +48,10 @@ export interface DistinctHost { readPresentationKind: SqlDriver['readPresentationKind']; } -/** The body of `SqlDriver#distinct`; the method's docblock there is its contract. */ -export async function distinct(this: Pick, object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise { +/** The bodies of the "Distinct" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class DistinctFacet { + /** The body of `SqlDriver#distinct`; the method's docblock there is its contract. */ + static async distinct(this: Pick, object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise { const builder = this.getBuilder(object, options); // The third read door that skipped the chokepoint, and the one #6792 does // NOT name — the card asserts the opposite ("called at 13 sites — …, @@ -127,3 +135,4 @@ export async function distinct(this: Pick this.presentReadValue(kind, v)))]; } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-facet-hosts.ts b/packages/drivers/driver-sql/src/sql-driver-facet-hosts.ts new file mode 100644 index 00000000000..61692099826 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-facet-hosts.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29520 codemod] The host of each facet of `SqlDriver`, built ONCE per driver (#30105): + * what every stub of a facet hands that facet's bodies as `this`, in place of + * a host literal built on every call. Each `FacetHost` below is filled by the + * factory `SqlDriver`'s static block installs, the one place that can read + * the class's private members; the host is built on the first call that needs + * it and kept for the driver's life. A method goes as a closure that calls it + * on the driver at call time, a field or accessor as a getter (and a setter + * when a body assigns it), so a body reads and writes the driver exactly as + * the per-call literal let it. + * + * Written by `scripts/codemod-split-sql-driver.mjs`; it imports types only, so it forms no + * evaluation cycle and is evaluated before the class whichever module loads + * first. `index.ts` re-exports nothing from this module. + */ + +import type { SqlDriver } from './sql-driver.js'; +import type { AggregationHost } from './sql-driver-aggregation.js'; +import type { BulkOperationsHost } from './sql-driver-bulk-operations.js'; +import type { ClassHeadHost } from './sql-driver-class-head.js'; +import type { ColumnCreationHost } from './sql-driver-column-creation.js'; +import type { CrudHost } from './sql-driver-crud.js'; +import type { DatabaseHelpersHost } from './sql-driver-database-helpers.js'; +import type { DeferredDdlHost } from './sql-driver-deferred-ddl.js'; +import type { DistinctHost } from './sql-driver-distinct.js'; +import type { FieldMappingHost } from './sql-driver-field-mapping.js'; +import type { FilterHelpersHost } from './sql-driver-filter-helpers.js'; +import type { InternalHelpersHost } from './sql-driver-internal-helpers.js'; +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 { 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'; +import type { TransactionsSectionHost } from './sql-driver-transactions-section.js'; +import type { WindowFunctionBuilderHost } from './sql-driver-window-function-builder.js'; +import type { WindowFunctionsHost } from './sql-driver-window-functions.js'; + +/** One facet's host for each driver: built on first use, kept for the driver's life. */ +export class FacetHost { + private readonly hosts = new WeakMap(); + private build: ((driver: SqlDriver) => H) | undefined; + + constructor(private readonly facet: string) {} + + /** Called once, from `SqlDriver`'s static block. */ + install(build: (driver: SqlDriver) => H): void { + this.build = build; + } + + /** The host of `driver`, built on first use. */ + of(driver: SqlDriver): H { + let host = this.hosts.get(driver); + if (host === undefined) { + if (this.build === undefined) { + throw new Error(`${this.facet}: SqlDriver has not installed its host factory`); + } + host = this.build(driver); + this.hosts.set(driver, host); + } + return host; + } +} + +export const aggregationHost = new FacetHost('AggregationFacet'); +export const bulkOperationsHost = new FacetHost('BulkOperationsFacet'); +export const classHeadHost = new FacetHost('ClassHeadFacet'); +export const columnCreationHost = new FacetHost('ColumnCreationFacet'); +export const crudHost = new FacetHost('CrudFacet'); +export const databaseHelpersHost = new FacetHost('DatabaseHelpersFacet'); +export const deferredDdlHost = new FacetHost('DeferredDdlFacet'); +export const distinctHost = new FacetHost('DistinctFacet'); +export const fieldMappingHost = new FacetHost('FieldMappingFacet'); +export const filterHelpersHost = new FacetHost('FilterHelpersFacet'); +export const internalHelpersHost = new FacetHost('InternalHelpersFacet'); +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 schemaSyncSectionHost = new FacetHost('SchemaSyncSectionFacet'); +export const sequenceSemanticsHost = new FacetHost('SequenceSemanticsFacet'); +export const sqliteSerialisationHost = new FacetHost('SqliteSerialisationFacet'); +export const transactionsSectionHost = new FacetHost('TransactionsSectionFacet'); +export const windowFunctionBuilderHost = new FacetHost('WindowFunctionBuilderFacet'); +export const windowFunctionsHost = new FacetHost('WindowFunctionsFacet'); diff --git a/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts b/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts index b07a604840a..c0291b83cf9 100644 --- a/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts +++ b/packages/drivers/driver-sql/src/sql-driver-field-mapping.ts @@ -2,53 +2,73 @@ /** * [#29520 codemod] The "Field mapping" section of `SqlDriver`: the bodies of - * `mapSortField`, `orderKeysFor`, `mapAggregateFunc` moved out of - * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * `mapSortField`, `paginationTieBreaker`, `orderKeysFor`, `remoteColumn`, + * `applyWriteColumnMap`, `mapAggregateFunc` moved out of `sql-driver.ts` whole + * by `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link FieldMappingFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 FieldMappingHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `fieldMappingHost` 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. + * 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 { DriverQuery } from '@objectstack/spec/contracts'; +import { StorageNameMapping } from '@objectstack/spec/system'; import { SQL_AGGREGATE_FUNCTIONS, refuseAggregateFunction } from './sql-driver-top-level.js'; import type { SqlAggregateLowering } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link FieldMappingFacet} 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 FieldMappingHost { + readonly fieldColumnByObject: SqlDriver['fieldColumnByObject']; readonly logger: SqlDriver['logger']; + readonly managedObjectFields: SqlDriver['managedObjectFields']; readonly nondeterministicPagingWarned: SqlDriver['nondeterministicPagingWarned']; paginationTieBreaker: SqlDriver['paginationTieBreaker']; } -/** The body of `SqlDriver#mapSortField`; the method's docblock there is its contract. */ -export function mapSortField(field: string): string { +/** The bodies of the "Field mapping" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class FieldMappingFacet { + /** The body of `SqlDriver#mapSortField`; the method's docblock there is its contract. */ + static mapSortField(field: string): string { if (field === 'createdAt') return 'created_at'; if (field === 'updatedAt') return 'updated_at'; return field; } -/** The body of `SqlDriver#orderKeysFor`; the method's docblock there is its contract. */ -export function orderKeysFor(this: Pick, object: string, query: DriverQuery, opts?: { singleRowLookup?: boolean }): Array<{ field: string; direction: 'asc' | 'desc' }> { + /** The body of `SqlDriver#paginationTieBreaker`; the method's docblock there is its contract. */ + static paginationTieBreaker(this: Pick, object: string): string | null { + const tableName = StorageNameMapping.resolveTableName({ name: object } as any); + const managed = + this.managedObjectFields.has(tableName) || this.managedObjectFields.has(object); + return managed ? 'id' : null; + } + + /** The body of `SqlDriver#orderKeysFor`; the method's docblock there is its contract. */ + static orderKeysFor(this: Pick, object: string, query: DriverQuery, opts?: { singleRowLookup?: boolean }): Array<{ field: string; direction: 'asc' | 'desc' }> { const keys: Array<{ field: string; direction: 'asc' | 'desc' }> = []; if (Array.isArray(query.orderBy)) { for (const item of query.orderBy) { @@ -87,9 +107,25 @@ export function orderKeysFor(this: Pick, object: string | null | undefined, field: string, fallback: string): string { + const m = object ? this.fieldColumnByObject[object] : undefined; + return (m && m[field]) || fallback; + } + + /** The body of `SqlDriver#applyWriteColumnMap`; the method's docblock there is its contract. */ + static applyWriteColumnMap(this: Pick, object: string, data: any): any { + const m = this.fieldColumnByObject[object]; + if (!m || !data || typeof data !== 'object') return data; + const out: any = {}; + for (const [k, v] of Object.entries(data)) out[m[k] ?? k] = v; + return out; + } + + /** The body of `SqlDriver#mapAggregateFunc`; the method's docblock there is its contract. */ + static mapAggregateFunc(func: string): SqlAggregateLowering { const lowering = SQL_AGGREGATE_FUNCTIONS.get(func); if (lowering !== undefined) return lowering; refuseAggregateFunction(func); } +} 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 7ce8c07b717..7af700ee3b7 100644 --- a/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts @@ -2,90 +2,133 @@ /** * [#29520 codemod] The "Filter helpers" section of `SqlDriver`: the bodies of - * `tableNameForBuilder`, `toDateOnly`, `coerceFilterValue`, + * `tableNameForBuilder`, `coercionKey`, `toDateOnly`, `toTimeOnly`, + * `coerceFilterValue`, `storageDatetimeValue`, `needsLegacyDatetimeRepair`, * `sqliteCanonicalDatetimeSql`, `sqliteNonTemporalTextSql`, - * `sqliteCanonicalTimeSql`, `readPresentationKind`, `presentReadValue`, - * `presentReadColumns`, `filterColumnExpr`, `logWithheldFilterDiagnostic`, - * `compileFilters` moved out of `sql-driver.ts` whole by + * `needsLegacyTimeRepair`, `sqliteCanonicalTimeSql`, `temporalFieldKind`, + * `readPresentationKind`, `presentReadValue`, `presentReadColumns`, + * `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`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * 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` * method of the same name exactly as it stood: not re-indented, not - * formatted, not edited. The method stays on the class with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 FilterHelpersHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `filterHelpersHost` 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. + * 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 { DriverOptions, ValueShapeFieldDef } from '@objectstack/spec/data'; import { presentAsNumber, 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, emptyFieldConstraintError, filterArrayReachedDriverError, - isEmptyFieldConstraint, normalizeSqliteDatetimeOutput, presentAuditTimestampOutput, refusalSubtree, - withheldFilterDiagnosticOf, + assertDefinedComparands, canonicalTimeOfDay, canonicalUtcDatetime, emptyFieldConstraintError, + filterArrayReachedDriverError, isEmptyFieldConstraint, 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'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link FilterHelpersFacet} 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 FilterHelpersHost { + applyFalseConstant: SqlDriver['applyFalseConstant']; applyFilterCondition: SqlDriver['applyFilterCondition']; + applyLike: SqlDriver['applyLike']; applyNormalizedComparison: SqlDriver['applyNormalizedComparison']; assertOperatorAppliesToColumn: SqlDriver['assertOperatorAppliesToColumn']; readonly booleanFields: SqlDriver['booleanFields']; + readonly canonicalDatetimeFields: SqlDriver['canonicalDatetimeFields']; + readonly canonicalTimeFields: SqlDriver['canonicalTimeFields']; coerceFilterValue: SqlDriver['coerceFilterValue']; coercionKey: SqlDriver['coercionKey']; + readonly dateFields: SqlDriver['dateFields']; + readonly datetimeFields: SqlDriver['datetimeFields']; filterColumnExpr: SqlDriver['filterColumnExpr']; + isJsonColumn: SqlDriver['isJsonColumn']; readonly isMysql: SqlDriver['isMysql']; readonly isSqlite: SqlDriver['isSqlite']; + readonly jsonFields: SqlDriver['jsonFields']; readonly logger: SqlDriver['logger']; + readonly managedObjectFields: SqlDriver['managedObjectFields']; + readonly mediaFields: SqlDriver['mediaFields']; needsLegacyDatetimeRepair: SqlDriver['needsLegacyDatetimeRepair']; needsLegacyTimeRepair: SqlDriver['needsLegacyTimeRepair']; readonly numericFields: SqlDriver['numericFields']; + readonly objectByPhysicalTable: SqlDriver['objectByPhysicalTable']; presentReadValue: SqlDriver['presentReadValue']; remoteColumn: SqlDriver['remoteColumn']; + resolveWithheldFilterRefusal: SqlDriver['resolveWithheldFilterRefusal']; sqliteCanonicalDatetimeSql: SqlDriver['sqliteCanonicalDatetimeSql']; sqliteCanonicalTimeSql: SqlDriver['sqliteCanonicalTimeSql']; storageDatetimeValue: SqlDriver['storageDatetimeValue']; + tableNameForBuilder: SqlDriver['tableNameForBuilder']; temporalFieldKind: SqlDriver['temporalFieldKind']; + readonly timeFields: SqlDriver['timeFields']; toDateOnly: SqlDriver['toDateOnly']; toTimeOnly: SqlDriver['toTimeOnly']; + readonly valueShapeFields: SqlDriver['valueShapeFields']; } -/** The body of `SqlDriver#tableNameForBuilder`; the method's docblock there is its contract. */ -export function tableNameForBuilder(builder: any): string | null { +/** The bodies of the "Filter helpers" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class FilterHelpersFacet { + /** The body of `SqlDriver#tableNameForBuilder`; the method's docblock there is its contract. */ + static tableNameForBuilder(builder: any): string | null { const t = builder?._single?.table; if (typeof t === 'string') return t; return null; } -/** The body of `SqlDriver#toDateOnly`; the method's docblock there is its contract. */ -export function toDateOnly(value: any): any { + /** The body of `SqlDriver#coercionKey`; the method's docblock there is its contract. */ + static coercionKey(this: Pick, builder: any): string | null { + const physical = this.tableNameForBuilder(builder); + if (physical == null) return null; + return this.objectByPhysicalTable[physical] ?? physical; + } + + /** The body of `SqlDriver#toDateOnly`; the method's docblock there is its contract. */ + static toDateOnly(value: any): any { // [#20176] The rule — UTC getters included — is `@objectstack/core`'s // `temporalStorageForm`, the function `driver-memory` and the engine's // per-aggregation `filter` / `having` read too; this body was a copy of it. return temporalStorageForm(value, 'date'); } -/** The body of `SqlDriver#coerceFilterValue`; the method's docblock there is its contract. */ -export function coerceFilterValue(this: Pick, table: string | null, field: string, value: any): any { + /** The body of `SqlDriver#toTimeOnly`; the method's docblock there is its contract. */ + static toTimeOnly(value: any): any { + return canonicalTimeOfDay(value); + } + + /** The body of `SqlDriver#coerceFilterValue`; the method's docblock there is its contract. */ + static coerceFilterValue(this: Pick, table: string | null, field: string, value: any): any { if (value == null || !table) return value; if (Array.isArray(value)) return value.map((v) => this.coerceFilterValue(table, field, v)); @@ -98,8 +141,21 @@ export function coerceFilterValue(this: Pick, value: unknown): unknown { + const canonical = canonicalUtcDatetime(value); + return this.isMysql ? mysqlDatetimeLiteral(canonical) : canonical; + } + + /** The body of `SqlDriver#needsLegacyDatetimeRepair`; the method's docblock there is its contract. */ + static needsLegacyDatetimeRepair(this: Pick, table: string | null | undefined, field: string): boolean { + if (!table || !this.isSqlite) return false; + if (this.datetimeFields[table]?.has(field) !== true) return false; + return this.canonicalDatetimeFields[table]?.has(field) !== true; + } + + /** The body of `SqlDriver#sqliteCanonicalDatetimeSql`; the method's docblock there is its contract. */ + static sqliteCanonicalDatetimeSql(columnSql: string): string { return ( `(case when typeof(${columnSql}) in ('integer','real') ` + `then strftime('%Y-%m-%dT%H:%M:%fZ', ${columnSql}/1000.0, 'unixepoch') ` + @@ -107,8 +163,8 @@ export function sqliteCanonicalDatetimeSql(columnSql: string): string { ); } -/** The body of `SqlDriver#sqliteNonTemporalTextSql`; the method's docblock there is its contract. */ -export function sqliteNonTemporalTextSql(columnSql: string): string { + /** The body of `SqlDriver#sqliteNonTemporalTextSql`; the method's docblock there is its contract. */ + static sqliteNonTemporalTextSql(columnSql: string): string { return ( `(typeof(${columnSql}) not in ('integer','real') ` + `and instr(${columnSql}, '-') = 0 and instr(${columnSql}, ':') = 0 ` + @@ -116,8 +172,15 @@ export function sqliteNonTemporalTextSql(columnSql: string): string { ); } -/** The body of `SqlDriver#sqliteCanonicalTimeSql`; the method's docblock there is its contract. */ -export function sqliteCanonicalTimeSql(columnSql: string): string { + /** The body of `SqlDriver#needsLegacyTimeRepair`; the method's docblock there is its contract. */ + static needsLegacyTimeRepair(this: Pick, table: string | null | undefined, field: string): boolean { + if (!table || !this.isSqlite) return false; + if (this.timeFields[table]?.has(field) !== true) return false; + return this.canonicalTimeFields[table]?.has(field) !== true; + } + + /** The body of `SqlDriver#sqliteCanonicalTimeSql`; the method's docblock there is its contract. */ + static sqliteCanonicalTimeSql(columnSql: string): string { const canonText = (args: string) => `case when strftime('%H:%M:%f', ${args}) like '%.000' ` + `then strftime('%H:%M:%S', ${args}) ` + @@ -129,8 +192,17 @@ export function sqliteCanonicalTimeSql(columnSql: string): string { ); } -/** The body of `SqlDriver#readPresentationKind`; the method's docblock there is its contract. */ -export function readPresentationKind(this: Pick, table: string | null | undefined, field: string): ReadPresentationKind | null { + /** The body of `SqlDriver#temporalFieldKind`; the method's docblock there is its contract. */ + static temporalFieldKind(this: Pick, table: string | null | undefined, field: string): 'datetime' | 'date' | 'time' | null { + if (!table) return null; + if (this.datetimeFields[table]?.has(field)) return 'datetime'; + if (this.dateFields[table]?.has(field)) return 'date'; + if (this.timeFields[table]?.has(field)) return 'time'; + return null; + } + + /** The body of `SqlDriver#readPresentationKind`; the method's docblock there is its contract. */ + static readPresentationKind(this: Pick, table: string | null | undefined, field: string): ReadPresentationKind | null { if (!table) return null; const temporal = this.temporalFieldKind(table, field); if (temporal) return temporal; @@ -147,8 +219,8 @@ export function readPresentationKind(this: Pick, kind: ReadPresentationKind, value: any): any { + /** The body of `SqlDriver#presentReadValue`; the method's docblock there is its contract. */ + static presentReadValue(this: Pick, kind: ReadPresentationKind, value: any): any { if (value == null) return value; switch (kind) { case 'date': @@ -186,8 +258,8 @@ export function presentReadValue(this: Pick, rows: any, columns: Map): any { + /** The body of `SqlDriver#presentReadColumns`; the method's docblock there is its contract. */ + static presentReadColumns(this: Pick, rows: any, columns: Map): any { if (columns.size === 0 || !Array.isArray(rows)) return rows; for (const row of rows) { if (!row || typeof row !== 'object') continue; @@ -198,8 +270,14 @@ export function presentReadColumns(this: Pick, table: string | null | undefined, field: string, column: string): { sql: string; bindings: any[] } | null { + /** The body of `SqlDriver#sqliteTemporalArg`; the method's docblock there is its contract. */ + static sqliteTemporalArg(this: Pick, field: string, table: string | null | undefined): { sql: string; bindings: any[] } { + if (!this.needsLegacyDatetimeRepair(table, field)) return { sql: '??', bindings: [field] }; + return { sql: this.sqliteCanonicalDatetimeSql('??'), bindings: [field, field, field, field] }; + } + + /** The body of `SqlDriver#filterColumnExpr`; the method's docblock there is its contract. */ + static filterColumnExpr(this: Pick, table: string | null | undefined, field: string, column: string): { sql: string; bindings: any[] } | null { if (this.needsLegacyDatetimeRepair(table, field)) { return { sql: this.sqliteCanonicalDatetimeSql('??'), @@ -215,8 +293,104 @@ export function filterColumnExpr(this: Pick, err: unknown): void { + /** The body of `SqlDriver#temporalFilterValue`; the method's docblock there is its contract. */ + static temporalFilterValue(this: Pick, objectName: string, field: string, value: any): unknown { + return this.coerceFilterValue(objectName, field, value); + } + + /** The body of `SqlDriver#temporalFilterColumnSql`; the method's docblock there is its contract. */ + static temporalFilterColumnSql(this: Pick, objectName: string, field: string, columnSql: string): string { + if (this.needsLegacyDatetimeRepair(objectName, field)) { + return this.sqliteCanonicalDatetimeSql(columnSql); + } + if (this.needsLegacyTimeRepair(objectName, field)) { + return this.sqliteCanonicalTimeSql(columnSql); + } + return columnSql; + } + + /** The body of `SqlDriver#isJsonColumn`; the method's docblock there is its contract. */ + static isJsonColumn(this: Pick, table: string | null | undefined, localField: string): boolean { + if (!table) return false; + return this.jsonFields[table]?.includes(localField) === true; + } + + /** The body of `SqlDriver#jsonColumnFieldClass`; the method's docblock there is its contract. */ + static jsonColumnFieldClass(this: Pick, table: string | null | undefined, localField: string): JsonColumnFieldClass | undefined { + if (!table || !this.isJsonColumn(table, localField)) return undefined; + return this.mediaFields[table]?.includes(localField) === true ? 'single-value-media' : 'multi-value-or-json'; + } + + /** The body of `SqlDriver#isNonTextColumn`; the method's docblock there is its contract. */ + static isNonTextColumn(this: Pick, table: string | null | undefined, localField: string): boolean { + if (!table) return false; + return ( + this.numericFields[table]?.includes(localField) === true || + (this.booleanFields[table]?.includes(localField) === true && !this.isJsonColumn(table, localField)) || + (this.temporalFieldKind(table, localField) !== null && !this.isJsonColumn(table, localField)) + ); + } + + /** + * [#14079] Emit the DECLARED answer for a text operator aimed at a column + * whose stored value is never text ({@link isNonTextColumn}): the positive + * operators (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / + * `$like` / `$ilike`) compile to the FALSE constant — no row's number + * contains a substring — and `$notContains` to the TRUE constant, its exact + * complement. `FILTER_TEXT_CASES`' `score` rows are the pin, on every face. + * + * # What compiled before, per dialect — three answers to one filter + * + * Measured on `origin/main` over a `Field.number` column holding `5` + * (#14079): the SQLite dialects compiled `col GLOB '*5*'` and COERCED the + * REAL to text in the storage class's own spelling (`'5.0'`, so + * `$contains: '.0'` matched and `$endsWith: '5'` did not); live Postgres + * compiled `col LIKE $1 ESCAPE $2` and REFUSED at query time with SQLSTATE + * 42883 (`operator does not exist: real ~~ text`) — a 500 for a filter the + * spec accepts; MySQL's `CAST(col AS BINARY) LIKE ?` reads as coercion (not + * executed — no server was provisionable). The maintainer ruled the cell on + * 2026-09-05 (option A, type-gate; coercion refused on the measurement) and + * the constants are that ruling compiled: the same row set on every dialect, + * decided by the DECLARED type at compile time because the stored value is + * not visible until run time. + * + * # Why constants compose with the NULL rules rather than fight them + * + * A row with no value already satisfies `$notContains` (#5298, + * {@link SqlDriver.applyNullSafeNegative}) and fails every positive + * operator, so `1 = 1` / `1 = 0` agree with the null polarity on every row — + * there is no cell where the two rules disagree. Under `$not` the leaf is + * totalised first (the shared lowering's rule 3, at the seams): `NOT (col IS NOT NULL + * AND 1 = 0)` is TRUE for every row, which is what the JS faces answer for + * `!contains` on a number, and `NOT (col IS NULL OR 1 = 1)` is FALSE for + * every row, what they answer for `!notContains`. Complementarity holds + * through the negation too. + * + * Every refusal stays AHEAD of this: the comparand gates on the validating + * walk (`reduceFilterKey`: an empty or non-string `$icontains` comparand, a + * non-string or dangling `$like` pattern) and {@link assertCompilableComparand} + * all run before the caller reaches here, so a filter the contract refuses + * is still refused — the constant answers only a filter the contract + * accepts. `1 = 0` is {@link applyFalseConstant}'s spelling; `1 = 1` is its + * mirror, valid on every dialect this driver targets and free of bindings. + */ + static applyTextOperatorOverNonTextColumn(this: Pick, builder: any, logicalOp: 'and' | 'or', op: string): void { + if (op === '$notContains') { + builder[logicalOp === 'or' ? 'orWhereRaw' : 'whereRaw']('1 = 1'); + return; + } + this.applyFalseConstant(builder, logicalOp); + } + + /** The body of `SqlDriver#declaredValueShape`; the method's docblock there is its contract. */ + static declaredValueShape(this: Pick, table: string | null | undefined, localField: string): ValueShapeFieldDef | undefined { + if (!table) return undefined; + const shapes = this.valueShapeFields[table]; + return shapes && Object.prototype.hasOwnProperty.call(shapes, localField) ? shapes[localField] : undefined; + } + + /** The body of `SqlDriver#logWithheldFilterDiagnostic`; the method's docblock there is its contract. */ + static logWithheldFilterDiagnostic(this: Pick, err: unknown): void { const diagnostic = withheldFilterDiagnosticOf(err); if (diagnostic === null) return; const marked = err as Record; @@ -235,8 +409,23 @@ export function logWithheldFilterDiagnostic(this: Pick, builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { + /** The body of `SqlDriver#withWithheldFilterLog`; the method's docblock there is its contract. */ + static withWithheldFilterLog(this: Pick, rootFilter: unknown, fn: () => T): T { + try { + return fn(); + } catch (err) { + // [#8220] The same provenance resolution `applyFilters` applies, against + // the same `where` ROOT — threaded into this frame because a lazily + // invoked group callback is past that entry point's catch. Resolving + // against the local branch instead would lose every mark set on an + // ANCESTOR (the merge boundaries mark the arms of their own `$and`), so + // an author's nested `$or` refusal would stay redacted for no reason. + throw this.resolveWithheldFilterRefusal(err, rootFilter); + } + } + + /** The body of `SqlDriver#compileFilters`; the method's docblock there is its contract. */ + static compileFilters(this: Pick, builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { if (!filters) return; // [#5158] `where` is a `FilterCondition` OBJECT. It always was — the spec @@ -324,3 +513,36 @@ export function compileFilters(this: Pick, builder: any, method: string, field: string, value: unknown): void { + this.applyLike(builder, method, field, value, 'contains'); + } + + /** The body of `SqlDriver#declaredFieldsFor`; the method's docblock there is its contract. */ + static declaredFieldsFor(this: Pick, object: string): Record | undefined { + const tableName = StorageNameMapping.resolveTableName({ name: object } as any); + return this.managedObjectFields.get(tableName) ?? this.managedObjectFields.get(object); + } + + /** + * [#5134] Emit the dialect FALSE constant — a predicate that matches no row. + * + * `1 = 0` is the spelling already used for this condition on both sides of the + * repo: `read-scope-sql.ts` compiles an empty `$in` to it, and Knex itself + * renders an empty `whereIn` as `1 = 0`. It is valid on every dialect this + * driver targets (unlike a bare `FALSE`, which MySQL accepts but older SQL + * Server does not), needs no bindings, and keeps the query a normal SELECT so + * `LIMIT`/`ORDER BY`/aggregates all still behave. + */ + static applyFalseConstant(builder: any, logicalOp: 'and' | 'or'): void { + builder[logicalOp === 'or' ? 'orWhereRaw' : 'whereRaw']('1 = 0'); + } +} 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 1cdd15a3b4b..e47052cc0c3 100644 --- a/packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts +++ b/packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts @@ -2,46 +2,82 @@ /** * [#29520 codemod] The "Internal helpers" section of `SqlDriver`: the bodies of - * `applyTenantScope`, `injectTenantOnInsert`, `auditMissingTenant` moved out of - * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. + * `getKnex`, `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. Each function below is the body of the `SqlDriver` + * 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` * method of the same name exactly as it stood: not re-indented, not - * formatted, not edited. The method stays on the class with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 InternalHelpersHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `internalHelpersHost` 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. + * 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 { ComposedReadableIds, DriverOptions } from '@objectstack/spec/data'; +import { StorageNameMapping } from '@objectstack/spec/system'; +import { resolveTenancyPosture } from '@objectstack/types'; +import { postureEnforcesWall } from '@objectstack/spec/security'; import type { Knex } from 'knex'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link InternalHelpersFacet} 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 InternalHelpersHost { + applyFilters: SqlDriver['applyFilters']; + applyTenantScope: SqlDriver['applyTenantScope']; + getBuilder: SqlDriver['getBuilder']; isMultiTenantMode: SqlDriver['isMultiTenantMode']; + readonly knex: SqlDriver['knex']; readonly logger: SqlDriver['logger']; + remoteColumn: SqlDriver['remoteColumn']; resolveTenantField: SqlDriver['resolveTenantField']; readonly tenantAuditWarned: SqlDriver['tenantAuditWarned']; + readonly tenantFieldByTable: SqlDriver['tenantFieldByTable']; } -/** The body of `SqlDriver#applyTenantScope`; the method's docblock there is its contract. */ -export function applyTenantScope(this: Pick, builder: Knex.QueryBuilder, object: string, options?: DriverOptions): Knex.QueryBuilder { +/** The bodies of the "Internal helpers" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class InternalHelpersFacet { + /** The body of `SqlDriver#getKnex`; the method's docblock there is its contract. */ + static getKnex(this: Pick): Knex { + return this.knex; + } + + /** 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); + const cached = + this.tenantFieldByTable[tableName] ?? this.tenantFieldByTable[object]; + return cached ?? null; + } + + /** The body of `SqlDriver#isMultiTenantMode`; the method's docblock there is its contract. */ + static isMultiTenantMode(): boolean { + return postureEnforcesWall(resolveTenancyPosture()); + } + + /** The body of `SqlDriver#applyTenantScope`; the method's docblock there is its contract. */ + static applyTenantScope(this: Pick, builder: Knex.QueryBuilder, object: string, options?: DriverOptions): Knex.QueryBuilder { const tenantId = options?.tenantId; if (tenantId === undefined || tenantId === null || tenantId === '') return builder; const field = this.resolveTenantField(object); @@ -74,8 +110,17 @@ export function applyTenantScope(this: Pick, object: string, row: Record, options?: DriverOptions): void { + /** The body of `SqlDriver#readableIdsSubquery`; the method's docblock there is its contract. */ + static readableIdsSubquery(this: Pick, leaf: ComposedReadableIds, options?: DriverOptions): Knex.QueryBuilder { + const parent = leaf.object; + const sub = this.getBuilder(parent).select(this.remoteColumn(parent, 'id', 'id')); + this.applyTenantScope(sub, parent, options); + if (leaf.scope !== undefined) this.applyFilters(sub, leaf.scope, options); + return sub; + } + + /** The body of `SqlDriver#injectTenantOnInsert`; the method's docblock there is its contract. */ + static injectTenantOnInsert(this: Pick, object: string, row: Record, options?: DriverOptions): void { const tenantId = options?.tenantId; if (tenantId === undefined || tenantId === null || tenantId === '') return; const field = this.resolveTenantField(object); @@ -85,8 +130,8 @@ export function injectTenantOnInsert(this: Pick, object: string, op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert', options?: DriverOptions, insertRows?: readonly unknown[]): void { + /** The body of `SqlDriver#auditMissingTenant`; the method's docblock there is its contract. */ + static auditMissingTenant(this: Pick, object: string, op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert', options?: DriverOptions, insertRows?: readonly unknown[]): void { if (process.env.OS_TENANT_AUDIT === '0') return; if (options?.bypassTenantAudit === true) return; // A write that DID carry its tenant is the case this audit has nothing to @@ -124,3 +169,4 @@ export function auditMissingTenant(this: Pick): Promise { +/** The bodies of the "Lifecycle" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class LifecycleFacet { + /** The body of `SqlDriver#connect`; the method's docblock there is its contract. */ + static async connect(this: Pick): Promise { try { // Ensure the database directory exists before any query can trigger // better-sqlite3 to open the file (e.g. loadMetaFromDb on startup). @@ -64,3 +92,322 @@ export async function connect(this: Pick): Promise { + try { + await this.knex.destroy(); + this.knex.initialize(); + } catch (e) { + this.logger.warn('Failed to close the SQLite connection a refused connect() opened', e); + } + } + + /** + * SQLite space hygiene (ADR-0057). With the default `auto_vacuum=NONE`, + * freed pages are never returned to the OS — a database that briefly grew + * (e.g. high-frequency telemetry before retention sweeps run) stays at its + * high-water mark forever. INCREMENTAL lets a later `PRAGMA incremental_vacuum` + * (run by the lifecycle Reaper, or manually) reclaim that space without a + * full blocking VACUUM. NOTE: auto_vacuum only changes layout on a *fresh* + * database or after a one-time full VACUUM, so this benefits new dev DBs; + * existing files need a single `VACUUM` to adopt it (`os db clean`). + * Unaffected by the journal mode: an INCREMENTAL database keeps reclaiming + * under WAL. + * + * ## Read first, set only when different (#21734) + * + * The setter is not free on a file that already answers INCREMENTAL: it + * runs a write transaction that stamps the header — the file change counter + * (bytes 24–27) and the version-valid-for number (bytes 92–95) — although + * the mode does not change. So every connect used to leave an + * already-incremental file different from how it found it, a read-only + * `os migrate duplicates` included. Reading `PRAGMA auto_vacuum` is + * byte-neutral (measured on an INCREMENTAL WAL file: md5 unchanged), so the + * setter is issued only when the file does not answer INCREMENTAL: + * + * - an already-incremental file: the read only — nothing is written; + * - a fresh file and `:memory:`: they answer NONE, get the setter, and come + * out INCREMENTAL exactly as before; + * - a legacy NONE file that holds tables: it gets the same setter it always + * got, which leaves it NONE until a `VACUUM` (this method runs none); + * - a FULL file: it gets the setter and moves to INCREMENTAL, as before. + * + * A reply this method cannot read counts as "not INCREMENTAL": an unreadable + * answer costs the old write, never the mode. + * + * @returns whether the connection answered. The read is the first statement + * any connection runs, so `false` says the connection itself is not answering — + * not merely that space hygiene is unavailable. + * @throws {SqliteDatabaseFileError} when that first statement's answer is a + * verdict on the file itself ({@link SQLITE_FILE_REFUSALS}): it cannot be + * opened, it is not a SQLite database, or its header or schema is damaged. + */ + static async applyAutoVacuumIncremental(this: Pick): Promise { + try { + const current = SqlDriver.autoVacuumOf(await this.knex.raw('PRAGMA auto_vacuum')); + if (current !== SqlDriver.SQLITE_AUTO_VACUUM_INCREMENTAL) { + await this.knex.raw('PRAGMA auto_vacuum = INCREMENTAL'); + } + return true; + } catch (e) { + // A native better-sqlite3 load failure surfaces HERE first — this PRAGMA + // is the first query that forces the lazy `.node` addon to load. Two + // real-world variants, both handled by `resolveSqliteDriver`'s probe, + // which catches the failure and steps down to wasm SQLite with a clean + // one-line notice (#2229): + // • ABI mismatch — `ERR_DLOPEN_FAILED` / "…NODE_MODULE_VERSION 127…" + // (a stale prebuilt binary after a Node upgrade) + // • not built — "Could not locate the bindings file" (native addon + // never compiled, e.g. a fresh clone / blocked build) + // Dumping the full multi-line stack for either looks like a fatal crash + // to the reader (it isn't), so log a concise, actionable one-liner and + // let the step-down message that follows explain the outcome. Any OTHER + // PRAGMA failure keeps the full warning (with stack) as before. + const code = (e as { code?: string } | null | undefined)?.code; + // The file itself is refused, and every later statement would answer the + // same, so the connect fails here, naming the file. Before, each of these + // logged and resolved, and the datasource counted as connected while it + // could not answer one query: + // • SQLITE_CANTOPEN (#23696) — a directory that exists but refuses the + // file, a path SQLite cannot open; + // • SQLITE_NOTADB (#24960) — the file exists and opens, but its bytes + // are not a SQLite database (plain text, a foreign format, a file + // encrypted by another engine). On a development boot the step-down + // used to answer it with wasm SQLite, whose open moved the + // operator's file aside and wrote an empty database in its place. + // The refusal is issued before anything is written: the file is left + // exactly as it was found; + // • SQLITE_CORRUPT (#25019) — the file is a SQLite database SQLite + // cannot read: its header or its schema is damaged (a file truncated + // inside its 100-byte header, or short of the pages its schema + // names). At the FIRST statement this is a verdict on the whole + // file, measured: `SELECT 1` answers the same code. A database + // damaged deeper inside answers this read and connects; its damage + // surfaces on the read that reaches it, as before. A development + // boot used to take the same step-down to wasm SQLite as for + // SQLITE_NOTADB, and the same rename; it is refused the same way, + // before anything is written. + const refusal = code === undefined ? undefined : SqlDriver.SQLITE_FILE_REFUSALS.get(code); + if (refusal !== undefined) { + const declared = (this.config as any).connection; + const filename = this.sqliteFilename() ?? String(typeof declared === 'string' ? declared : declared?.filename); + throw new SqliteDatabaseFileError(filename, refusal, e); + } + const msg = e instanceof Error ? e.message : String(e); + const isNativeLoadFailure = + code === 'ERR_DLOPEN_FAILED' || + code === 'MODULE_NOT_FOUND' || + code === 'ERR_MODULE_NOT_FOUND' || + /NODE_MODULE_VERSION|could not locate the bindings|was compiled against a different/i.test(msg); + if (isNativeLoadFailure) { + this.logger.warn( + 'native better-sqlite3 unavailable (ABI mismatch or not built) — will step down to wasm SQLite; run `pnpm rebuild better-sqlite3` for native speed', + ); + } else { + this.logger.warn('Failed to set PRAGMA auto_vacuum=INCREMENTAL', e); + } + return false; + } + } + + /** + * The `auto_vacuum` mode a PRAGMA reply carries, as a number. + * + * Both SQLite dialects answer with rows (`[{ auto_vacuum: 2 }]`) — a reply + * shaped any other way yields `null`, which {@link applyAutoVacuumIncremental} + * treats as "not INCREMENTAL" and answers with the setter. + */ + static autoVacuumOf(result: unknown): number | null { + const rows: unknown = Array.isArray(result) ? result : (result as { rows?: unknown })?.rows; + const row = Array.isArray(rows) ? rows[0] : undefined; + const value = + row && typeof row === 'object' ? (row as { auto_vacuum?: unknown }).auto_vacuum : row; + if (typeof value === 'number') return value; + if (typeof value === 'bigint') return Number(value); + return null; + } + + /** The body of `SqlDriver#resolveSqliteJournalMode`; the method's docblock there is its contract. */ + static resolveSqliteJournalMode(this: Pick): SqliteJournalMode { + const fromEnv = (process.env[SqlDriver.SQLITE_JOURNAL_MODE_ENV] ?? '').trim().toLowerCase(); + const requested = this.declaredJournalMode ?? (fromEnv || undefined); + if (requested === undefined) return 'wal'; + if (requested === 'wal' || requested === 'delete') return requested; + this.logger.warn( + `Ignoring SQLite journal mode '${requested}' (expected 'wal' or 'delete') — using 'wal'`, + ); + return 'wal'; + } + + /** The body of `SqlDriver#applySqliteJournalMode`; the method's docblock there is its contract. */ + static async applySqliteJournalMode(this: Pick): Promise { + if (!this.supportsWalJournal) return; + + // No file means nobody to share it with: `:memory:` (and every other + // `:`-prefixed pseudo-filename) is private to this process. + const filename = this.sqliteFilename(); + if (!filename) return; + + const target = this.resolveSqliteJournalMode(); + + let current: string | null; + try { + current = SqlDriver.journalModeOf(await this.knex.raw('PRAGMA journal_mode')); + } catch (e) { + this.logger.warn(`Failed to read SQLite journal_mode on ${filename}`, e); + return; + } + + // `memory` / `off` is a deliberate no-journal transport, not a rollback + // journal worth migrating — leave whatever the host chose in place. + if (current === null || current === 'memory' || current === 'off') return; + + if (current === target) { + // Nothing to change — but when the mode is already WAL, still prove it is + // usable. A database an earlier boot left in WAL on a filesystem that + // cannot serve it (failure mode 2) would otherwise fail every query with + // nothing pointing at the cause, and the revert that would fix it only + // ever gets one attempt. + if (target === 'wal') await this.verifyWalReadable(filename); + return; + } + + let applied: string | null; + try { + applied = SqlDriver.journalModeOf( + await this.knex.raw(`PRAGMA journal_mode = ${target.toUpperCase()}`), + ); + } catch (e) { + // SQLITE_BUSY is the benign case: a mode change needs the file to itself, + // and another process has it open. Whoever wins the race sets the mode + // and everyone else reads it back on their next boot. + this.logger.warn( + `Failed to set SQLite journal_mode=${target} on ${filename} (it stays '${current}')`, + e, + ); + return; + } + + if (applied !== target) { + this.logger.warn( + `SQLite kept journal_mode='${applied ?? 'unknown'}' on ${filename} instead of '${target}' — ` + + (target === 'wal' + ? `WAL needs a local filesystem (it does not work on NFS/SMB), so this database stays serialized between processes. ` + + `Set ${SqlDriver.SQLITE_JOURNAL_MODE_ENV}=delete to stop asking.` + : `close every other connection and retry, or run \`sqlite3 ${filename} "PRAGMA journal_mode=delete"\` with nothing attached.`), + ); + return; + } + + if (target === 'wal') await this.verifyWalReadable(filename); + } + + /** + * Prove a WAL database can actually be read through, and undo the mode if + * not — see failure mode 2 in {@link applySqliteJournalMode}. Runs on every + * connect to a WAL database, not only right after a switch, so a database + * some earlier boot stranded still gets recovered. + * + * Any read on a WAL database opens the shared-memory index, which is the part + * a network filesystem cannot provide; `sqlite_master` is the cheapest such + * read and needs no schema to exist. + */ + static async verifyWalReadable(this: Pick, filename: string): Promise { + try { + await this.knex.raw('SELECT count(*) FROM sqlite_master'); + return; + } catch (e) { + // Busy is not unusable. Another connection in EXCLUSIVE locking mode — the + // `os migrate` occupancy probe takes exactly that, briefly — makes this + // read fail for a reason that passes on its own, and changing the journal + // mode underneath it would be both wrong and impossible. + if (SqlDriver.isSqliteBusyError(e)) return; + + const detail = e instanceof Error ? e.message : String(e); + let reverted: string | null = null; + try { + reverted = SqlDriver.journalModeOf(await this.knex.raw('PRAGMA journal_mode = DELETE')); + } catch { + // Leaving `reverted` null — reported as "did not take" below, which is + // the honest outcome either way. + } + this.logger.warn( + `SQLite accepted WAL on ${filename} but cannot read through it (${detail}) — ` + + (reverted === 'delete' + ? `reverted to journal_mode=delete. Set ${SqlDriver.SQLITE_JOURNAL_MODE_ENV}=delete to skip this on every boot.` + : `and the revert did not take (journal_mode='${reverted ?? 'unknown'}'). Run ` + + `\`sqlite3 ${filename} "PRAGMA journal_mode=delete"\` with nothing attached, then set ` + + `${SqlDriver.SQLITE_JOURNAL_MODE_ENV}=delete.`), + ); + } + } + + /** + * Whether an error is SQLite reporting contention rather than a broken + * database. Mirrors `isBusyError` in the CLI's occupancy probe, which asks the + * same question of the same engine from the other side of the package graph. + */ + static isSqliteBusyError(e: unknown): boolean { + const code = (e as { code?: string } | null | undefined)?.code ?? ''; + const message = e instanceof Error ? e.message : String(e ?? ''); + return code.startsWith('SQLITE_BUSY') || /database is locked|SQLITE_BUSY/i.test(message); + } + + /** + * The `journal_mode` a PRAGMA reply carries, lower-cased. + * + * Both SQLite dialects answer a PRAGMA with rows (`[{ journal_mode: 'wal' }]`) + * — a reply shaped any other way yields `null`, which every caller treats as + * "leave the database alone" rather than guessing. + */ + static journalModeOf(result: unknown): string | null { + const rows: unknown = Array.isArray(result) ? result : (result as { rows?: unknown })?.rows; + const row = Array.isArray(rows) ? rows[0] : undefined; + const value = + row && typeof row === 'object' ? (row as { journal_mode?: unknown }).journal_mode : row; + return typeof value === 'string' ? value.toLowerCase() : null; + } + + /** The body of `SqlDriver#checkHealth`; the method's docblock there is its contract. */ + static async checkHealth(this: Pick): Promise { + try { + await this.knex.raw('SELECT 1'); + return true; + } catch { + return false; + } + } + + /** The body of `SqlDriver#disconnect`; the method's docblock there is its contract. */ + static async disconnect(this: Pick): Promise { + await this.knex.destroy(); + } +} 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 56b17736222..ebe8a6c4bc5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts +++ b/packages/drivers/driver-sql/src/sql-driver-managed-drift.ts @@ -3,41 +3,49 @@ /** * [#29520 codemod] The "Managed-schema drift & reconcile (#2186)" section of * `SqlDriver`: the bodies of `isProductionEnv`, - * `probeNullSafeUniqueDuplicates`, `buildRebuiltColumn`, `dropIndexIfExists`, + * `probeNullSafeUniqueDuplicates`, `buildRebuiltColumn`, `buildIndexName`, + * `getExistingIndexNames`, `uniqueIndexesFromFields`, `dropIndexIfExists`, * `syncTableIndexes` moved out of `sql-driver.ts` whole by * `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link ManagedDriftFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 ManagedDriftHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `managedDriftHost` 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. + * 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 { GLOBAL_TENANT } from './schema-drift.js'; +import { GLOBAL_TENANT, buildIndexName, uniqueIndexesFromFields } 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'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link ManagedDriftFacet} 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 ManagedDriftHost { + introspectIndexes: SqlDriver['introspectIndexes']; readonly isMysql: SqlDriver['isMysql']; readonly isPostgres: SqlDriver['isPostgres']; readonly knex: SqlDriver['knex']; @@ -45,8 +53,10 @@ export interface ManagedDriftHost { uniqueIndexesFromFields: SqlDriver['uniqueIndexesFromFields']; } -/** The body of `SqlDriver#isProductionEnv`; the method's docblock there is its contract. */ -export function isProductionEnv(): boolean { +/** 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#isProductionEnv`; the method's docblock there is its contract. */ + static isProductionEnv(): boolean { try { return (process.env.NODE_ENV ?? '').toLowerCase() === 'production'; } catch { @@ -54,8 +64,8 @@ export function isProductionEnv(): boolean { } } -/** The body of `SqlDriver#probeNullSafeUniqueDuplicates`; the method's docblock there is its contract. */ -export async function probeNullSafeUniqueDuplicates(this: Pick, table: string, columns: string[], nullSafeColumns: string[]): Promise> { + /** The body of `SqlDriver#probeNullSafeUniqueDuplicates`; the method's docblock there is its contract. */ + static async probeNullSafeUniqueDuplicates(this: Pick, table: string, columns: string[], nullSafeColumns: string[]): Promise> { const ns = new Set(nullSafeColumns); const q = (id: string) => this.knex.ref(id).toQuery(); const parts = columns.map((c) => @@ -75,8 +85,8 @@ export async function probeNullSafeUniqueDuplicates(this: Pick, tableName: string, indexName: string): Promise { + /** The body of `SqlDriver#buildIndexName`; the method's docblock there is its contract. */ + static buildIndexName(tableName: string, fields: string[], unique: boolean): string { + return buildIndexName(tableName, fields, unique); + } + + /** The body of `SqlDriver#getExistingIndexNames`; the method's docblock there is its contract. */ + static async getExistingIndexNames(this: Pick, tableName: string): Promise> { + return new Set((await this.introspectIndexes(tableName, { onFailure: 'partial' })).map((i) => i.name)); + } + + /** The body of `SqlDriver#uniqueIndexesFromFields`; the method's docblock there is its contract. */ + static uniqueIndexesFromFields(tableName: string, fields: Record, tenantField: string | null): Array<{ name: string; fields: string[]; unique: true; nullSafeColumns?: string[] }> { + return uniqueIndexesFromFields(tableName, fields, tenantField).map((i) => ({ + name: i.name, + fields: i.columns, + unique: true as const, + ...(i.nullSafeColumns ? { nullSafeColumns: i.nullSafeColumns } : {}), + })); + } + + /** The body of `SqlDriver#dropIndexIfExists`; the method's docblock there is its contract. */ + static async dropIndexIfExists(this: Pick, tableName: string, indexName: string): Promise { const attempts: string[] = this.isPostgres ? [ `ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??`, @@ -118,8 +148,8 @@ export async function dropIndexIfExists(this: Pick, tableName: string, fields: Record, declaredIndexes: any[] | undefined, tenantField: string | null, physicalColumns: Set): Promise { + /** The body of `SqlDriver#syncTableIndexes`; the method's docblock there is its contract. */ + static async syncTableIndexes(this: Pick, tableName: string, fields: Record, declaredIndexes: any[] | undefined, tenantField: string | null, physicalColumns: Set): Promise { const fromFields = this.uniqueIndexesFromFields(tableName, fields, tenantField); const declared = Array.isArray(declaredIndexes) ? declaredIndexes : []; if (fromFields.length === 0 && declared.length === 0) return; @@ -129,3 +159,4 @@ export async function syncTableIndexes(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise { +/** The bodies of the "Query Plan Analysis" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class QueryPlanFacet { + /** The body of `SqlDriver#explain`; the method's docblock there is its contract. */ + static async explain(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise { + return this.analyzeQuery(object, query, options); + } + + /** The body of `SqlDriver#analyzeQuery`; the method's docblock there is its contract. */ + static async analyzeQuery(this: Pick, object: string, query: DriverQuery, options?: DriverOptions): Promise { const builder = this.getBuilder(object, options); // A PLAN, not rows — so this is a smaller fix than the one above, and it is // made on its own merits rather than riding in on that one (#6792). It is @@ -119,3 +133,4 @@ export async function analyzeQuery(this: Pick; tenancy?: any }): string | null { +/** The bodies of the "Data-lifecycle rotation (ADR-0057 P2)" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class RotationFacet { + /** The body of `SqlDriver#rotationShardsOf`; the method's docblock there is its contract. */ + static rotationShardsOf(this: Pick, object: string): string[] | undefined { + return this.rotationStateByTable.get(object)?.shards; + } + + /** The body of `SqlDriver#rotationWriteTarget`; the method's docblock there is its contract. */ + static rotationWriteTarget(this: Pick, object: string): string | undefined { + return this.rotationStateByTable.get(object)?.current; + } + + /** The body of `SqlDriver#rotateShards`; the method's docblock there is its contract. */ + static async rotateShards(this: Pick, objectDef: { name: string; fields?: Record; tenancy?: any; indexes?: any[]; lifecycle?: any }, nowMs: number = Date.now()): Promise<{ object: string; current: string; shards: string[]; dropped: string[] }> { + this.assertSchemaMutable('rotateShards'); + const policy = objectDef.lifecycle?.storage; + if (!policy || policy.strategy !== 'rotation') { + throw new Error(`[sql-driver] rotateShards: '${objectDef.name}' declares no lifecycle.storage rotation policy`); + } + if (!this.supportsRotation) { + throw new Error(`[sql-driver] rotateShards: rotation is not supported on dialect '${this.dialectName}'`); + } + const tableName = StorageNameMapping.resolveTableName(objectDef as any); + return this.ensureRotation(tableName, objectDef, policy, nowMs); + } + + /** The body of `SqlDriver#aliasShardBookkeeping`; the method's docblock there is its contract. */ + static aliasShardBookkeeping(this: Pick, base: string, shard: string): void { + this.jsonFields[shard] = this.jsonFields[base] ?? []; + this.valueShapeFields[shard] = this.valueShapeFields[base] ?? {}; + this.mediaFields[shard] = this.mediaFields[base] ?? []; + this.booleanFields[shard] = this.booleanFields[base] ?? []; + this.numericFields[shard] = this.numericFields[base] ?? []; + this.numericValueFields[shard] = this.numericValueFields[base] ?? []; + this.fractionalNumericFields[shard] = this.fractionalNumericFields[base] ?? []; + this.autoNumberFields[shard] = this.autoNumberFields[base] ?? []; + if (this.dateFields[base]) this.dateFields[shard] = this.dateFields[base]; + if (this.datetimeFields[base]) this.datetimeFields[shard] = this.datetimeFields[base]; + if (this.timeFields[base]) this.timeFields[shard] = this.timeFields[base]; + this.tenantFieldByTable[shard] = this.tenantFieldByTable[base] ?? null; + if (this.tenantOptOutByTable.has(base)) this.tenantOptOutByTable.add(shard); + this.tablesWithTimestamps.add(shard); + } + + /** The body of `SqlDriver#computeTenantField`; the method's docblock there is its contract. */ + static computeTenantField(schema: { fields?: Record; tenancy?: any }): string | null { const tenancyDecl = (schema as any)?.tenancy; // Explicit opt-out wins over any column-presence heuristic. if (isTenancyDisabled(schema)) return null; @@ -75,8 +138,20 @@ export function computeTenantField(schema: { fields?: Record; tenan return null; } -/** The body of `SqlDriver#registerManagedObjectMetadata`; the method's docblock there is its contract. */ -export function registerManagedObjectMetadata(this: Pick, obj: { name: string; fields?: Record; tenancy?: any; indexes?: any[] }): { tableName: string; tenantField: string | null } { + /** The body of `SqlDriver#computeAndRecordTenantField`; the method's docblock there is its contract. */ + static computeAndRecordTenantField(this: Pick, key: string, schema: { fields?: Record; tenancy?: any }): string | null { + if (key === SystemObjectName.ORGANIZATION) this.organizationObjectRegistered = true; + if (schema?.tenancy != null) { + if (isTenancyDisabled(schema)) this.tenantOptOutByTable.add(key); + else this.tenantOptOutByTable.delete(key); + return this.computeTenantField(schema); + } + if (this.tenantOptOutByTable.has(key)) return null; + return this.computeTenantField(schema); + } + + /** The body of `SqlDriver#registerManagedObjectMetadata`; the method's docblock there is its contract. */ + static registerManagedObjectMetadata(this: Pick, obj: { name: string; fields?: Record; tenancy?: any; indexes?: any[] }): { tableName: string; tenantField: string | null } { const tableName = StorageNameMapping.resolveTableName(obj); // #2186: remember the authoritative metadata field set for this table so // drift detection / `os migrate` can diff the physical schema against it. @@ -192,3 +267,15 @@ export function registerManagedObjectMetadata(this: Pick, objects: Array<{ name: string; fields?: Record; tenancy?: any; indexes?: any[] }>): void { + for (const obj of objects) this.registerManagedObjectMetadata(obj); + } + + /** The body of `SqlDriver#withPinnedSession`; the method's docblock there is its contract. */ + static async withPinnedSession(this: Pick, fn: (run: (sql: string, bindings?: unknown[]) => Promise) => Promise): Promise { + return await this.knex.transaction(async (trx) => + fn((sql, bindings) => trx.raw(sql, (bindings ?? []) as any))); + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts b/packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts index a0914c6477c..4bf8aa91a14 100644 --- a/packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts +++ b/packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts @@ -2,25 +2,30 @@ /** * [#29520 codemod] The "Schema Sync (syncSchema / init)" section of - * `SqlDriver`: the bodies of `reclaimSpace` moved out of `sql-driver.ts` whole - * by `scripts/codemod-split-sql-driver.mjs`. + * `SqlDriver`: the bodies of `syncSchema`, `dropTable`, `reclaimSpace` moved + * out of `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link SchemaSyncSectionFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 SchemaSyncSectionHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `schemaSyncSectionHost` 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. + * 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'; @@ -28,18 +33,38 @@ import { reclaimBetterSqlite3 } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link SchemaSyncSectionFacet} 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 SchemaSyncSectionHost { + assertSchemaMutable: SqlDriver['assertSchemaMutable']; + initObjects: SqlDriver['initObjects']; readonly isSqlite: SqlDriver['isSqlite']; readonly knex: SqlDriver['knex']; } -/** The body of `SqlDriver#reclaimSpace`; the method's docblock there is its contract. */ -export async function reclaimSpace(this: Pick, _options?: DriverOptions): Promise { +/** The bodies of the "Schema Sync (syncSchema / init)" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class SchemaSyncSectionFacet { + /** The body of `SqlDriver#syncSchema`; the method's docblock there is its contract. */ + static async syncSchema(this: Pick, object: string, schema: unknown, _options?: DriverOptions): Promise { + const objectDef = schema as { name: string; fields?: Record }; + // The caller passes the resolved physical table name as `object`. Override + // the def's `name` to ensure DDL targets the physical table even if the + // schema's `name` is the canonical object name (e.g. 'account'). + await this.initObjects([{ ...objectDef, name: object }]); + } + + /** The body of `SqlDriver#dropTable`; the method's docblock there is its contract. */ + static async dropTable(this: Pick, object: string, _options?: DriverOptions): Promise { + this.assertSchemaMutable('dropTable'); + await this.knex.schema.dropTableIfExists(object); + } + + /** The body of `SqlDriver#reclaimSpace`; the method's docblock there is its contract. */ + static async reclaimSpace(this: Pick, _options?: DriverOptions): Promise { if (!this.isSqlite) return; const client = this.knex.client; if (client.driverName === 'better-sqlite3') { @@ -53,3 +78,4 @@ export async function reclaimSpace(this: Pick, t: Knex.CreateTableBuilder): void { +/** The bodies of the "The sequence semantics a second face shares, stated once" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class SequenceSemanticsFacet { + /** The body of `SqlDriver#resolveSequenceTenantId`; the method's docblock there is its contract. */ + static resolveSequenceTenantId(this: Pick, tenantField: string | null, tenantId: string | null, table: string, field: string): string { + if (!tenantField) return GLOBAL_TENANT; + if (tenantId) return String(tenantId); + if (this.organizationObjectRegistered) throw new OrganizationlessCounterRefusal(table, field, tenantField); + return GLOBAL_TENANT; + } + + /** The body of `SqlDriver#defineSequencesTable`; the method's docblock there is its contract. */ + static defineSequencesTable(this: Pick, t: Knex.CreateTableBuilder): void { t.string('key_hash', 64).notNullable().primary(); t.string('object').notNullable(); t.string('tenant_id').notNullable(); @@ -94,8 +134,83 @@ export function defineSequencesTable(this: Pick, t.timestamp('updated_at').defaultTo(this.knex.fn.now()); } -/** The body of `SqlDriver#autoNumberValueExists`; the method's docblock there is its contract. */ -export async function autoNumberValueExists(queryRunner: Knex | Knex.Transaction, reservation: AutoNumberReservation): Promise { + /** The body of `SqlDriver#createSequencesTable`; the method's docblock there is its contract. */ + static async createSequencesTable(this: Pick, table: string, runner: Knex | Knex.Transaction = this.knex): Promise { + await runner.schema.createTable(table, (t) => this.defineSequencesTable(t)); + } + + /** The body of `SqlDriver#ensureSequencesKeyHashShape`; the method's docblock there is its contract. */ + static async ensureSequencesKeyHashShape(this: Pick, runner: Knex | Knex.Transaction = this.knex): Promise { + if (await runner.schema.hasColumn(SEQUENCES_TABLE, 'key_hash')) { + this.sequencesHasKeyHash = true; + return; + } + const hasScope = await runner.schema.hasColumn(SEQUENCES_TABLE, 'scope'); + const TMP = `${SEQUENCES_TABLE}__rebuild`; + try { + const rows: any[] = await runner(SEQUENCES_TABLE).select('*'); + await runner.schema.dropTableIfExists(TMP); + await this.createSequencesTable(TMP, runner); + const migrated = rows.map((r) => { + const scope = hasScope && r.scope != null ? String(r.scope) : ''; + return { + key_hash: this.sequenceKeyHash(String(r.object), String(r.tenant_id), String(r.field), scope), + object: r.object, + tenant_id: r.tenant_id, + field: r.field, + scope, + last_value: r.last_value ?? 0, + updated_at: r.updated_at ?? this.knex.fn.now(), + }; + }); + if (migrated.length > 0) await runner(TMP).insert(migrated); + await runner.schema.dropTable(SEQUENCES_TABLE); + await runner.schema.renameTable(TMP, SEQUENCES_TABLE); + this.sequencesHasKeyHash = true; + } catch (err) { + // Leave the original table intact; fall back to legacy keying for + // fixed-prefix sequences and refuse per-scope writes until migrated. + this.sequencesHasKeyHash = false; + await runner.schema.dropTableIfExists(TMP).catch(() => {}); + this.logger.warn( + `[autonumber] Failed to migrate ${SEQUENCES_TABLE} to the key_hash shape. ` + + `Fixed-prefix autonumbers keep working; date/{field}/per-parent formats will ` + + `error until the table is migrated.`, + { error: String(err) }, + ); + } + } + + /** The body of `SqlDriver#scanMaxNumericTail`; the method's docblock there is its contract. */ + static async scanMaxNumericTail(this: Pick, queryRunner: Knex | Knex.Transaction, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, suffix = ''): Promise { + let builder = queryRunner(tableName) + .select(field) + .whereRaw('?? like ? escape ?', [field, `${this.escapeLikePrefix(prefix)}%`, LIKE_ESCAPE_CHARACTER]) + .whereNotNull(field); + if (tenantField && tenantId !== null) { + builder = builder.where(tenantField, tenantId); + } + const rows = await builder; + return this.maxAutonumberCounter((rows as any[]).map((r) => (r as any)[field]), prefix, suffix); + } + + /** The body of `SqlDriver#escapeLikePrefix`; the method's docblock there is its contract. */ + static escapeLikePrefix(prefix: string): string { + return prefix.replace(/([\\%_])/g, '\\$1'); + } + + /** The body of `SqlDriver#attemptWithoutPoisoning`; the method's docblock there is its contract. */ + static async attemptWithoutPoisoning(trx: Knex.Transaction, attempt: (scoped: Knex.Transaction) => Promise | Knex.QueryBuilder): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> { + try { + const value = await trx.transaction(async (scoped) => attempt(scoped)); + return { ok: true, value: value as T }; + } catch (error) { + return { ok: false, error }; + } + } + + /** The body of `SqlDriver#autoNumberValueExists`; the method's docblock there is its contract. */ + static async autoNumberValueExists(queryRunner: Knex | Knex.Transaction, reservation: AutoNumberReservation): Promise { let builder = queryRunner(reservation.tableName).select(reservation.field).where(reservation.field, reservation.value); if (reservation.tenantField && reservation.tenantId !== null) { builder = builder.where(reservation.tenantField, reservation.tenantId); @@ -104,8 +219,8 @@ export async function autoNumberValueExists(queryRunner: Knex | Knex.Transaction return hit !== undefined && hit !== null; } -/** The body of `SqlDriver#autoNumberCounterKey`; the method's docblock there is its contract. */ -export function autoNumberCounterKey(this: Pick, reservation: AutoNumberReservation): string { + /** The body of `SqlDriver#autoNumberCounterKey`; the method's docblock there is its contract. */ + static autoNumberCounterKey(this: Pick, reservation: AutoNumberReservation): string { const resolvedTenantId = this.resolveSequenceTenantId( reservation.tenantField, reservation.tenantId, @@ -115,8 +230,52 @@ export function autoNumberCounterKey(this: Pick, object: string, id: string | number, data: Record, options?: DriverOptions): Promise | null> { + /** The body of `SqlDriver#stampInsertTimestamps`; the method's docblock there is its contract. */ + static stampInsertTimestamps(this: Pick, object: string, formatted: Record): void { + if (!this.isSqlite || !this.tablesWithTimestamps.has(object)) return; + const iso = new Date().toISOString(); + for (const col of AUDIT_TIMESTAMP_COLUMNS) { + if (formatted[col] === undefined || formatted[col] === null) formatted[col] = iso; + } + } + + /** The body of `SqlDriver#observedUpdatedAtColumn`; the method's docblock there is its contract. */ + static observedUpdatedAtColumn(this: Pick, object: string): boolean { + return this.tablesWithTimestamps.has(object) || this.updatedAtColumnState.get(object) === 'present'; + } + + /** The body of `SqlDriver#stampUpsertUpdatedAt`; the method's docblock there is its contract. */ + static stampUpsertUpdatedAt(this: Pick, object: string, formatted: Record): void { + if (formatted.updated_at !== undefined && formatted.updated_at !== null) return; + if (!this.observedUpdatedAtColumn(object)) return; + formatted.updated_at = this.updatedAtStamp(); + } + + /** The body of `SqlDriver#keepSuppliedUpdatedAt`; the method's docblock there is its contract. */ + static keepSuppliedUpdatedAt(formatted: Record, options?: DriverOptions): boolean { + return options?.preserveAudit === true && formatted.updated_at != null; + } + + /** The body of `SqlDriver#updatedAtStamp`; the method's docblock there is its contract. */ + static updatedAtStamp(this: Pick): string | Knex.Raw { + if (this.isSqlite) return new Date().toISOString(); + return this.isMysql ? this.knex.fn.now(MYSQL_DATETIME_PRECISION) : this.knex.fn.now(); + } + + /** The body of `SqlDriver#stampsUpdatedAt`; the method's docblock there is its contract. */ + static stampsUpdatedAt(this: Pick, object: string): boolean { + if (this.tablesWithTimestamps.has(object)) return true; + const state = this.updatedAtColumnState.get(object); + return state === 'presumed' || state === 'present'; + } + + /** The body of `SqlDriver#updatedAtStampIsPresumed`; the method's docblock there is its contract. */ + static updatedAtStampIsPresumed(this: Pick, object: string): boolean { + return !this.tablesWithTimestamps.has(object) && this.updatedAtColumnState.get(object) === 'presumed'; + } + + /** The body of `SqlDriver#update`; the method's docblock there is its contract. */ + static async update(this: Pick, object: string, id: string | number, data: Record, options?: DriverOptions): Promise | null> { this.auditMissingTenant(object, 'update', options); const rotationShards = this.rotationShardsOf(object); if (rotationShards) return this.rotatedUpdateById(object, rotationShards, id, data, options); @@ -155,8 +314,122 @@ export async function update(this: Pick, object: string, writeTable: string, id: string | number, rivals: PhysicalIndex[], writtenTenant: unknown, options?: DriverOptions): Promise { + /** The body of `SqlDriver#insertOnlyUpsertColumns`; the method's docblock there is its contract. */ + static insertOnlyUpsertColumns(this: Pick, object: string): Set { + // Same config resolution as `fillAutoNumberFields`: object name first, + // then the storage-mapped table name. + const tableName = this.physicalTableByObject[object] ?? StorageNameMapping.resolveTableName({ name: object } as any); + const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName] || []; + const columns = new Set(['created_at', this.remoteColumn(object, 'id', 'id')]); + for (const cfg of cfgs) columns.add(this.remoteColumn(object, cfg.name, cfg.name)); + const tenantField = this.resolveTenantField(object); + if (tenantField) columns.add(this.remoteColumn(object, tenantField, tenantField)); + return columns; + } + + /** + * [#21185] The merge leg's organization predicate on the dialects whose + * `ON CONFLICT … DO UPDATE` takes a `WHERE`: the stored row's tenant column + * is NOT DISTINCT from the one the statement would have inserted + * (`excluded`). SQLite spells NULL-safe equality `IS`, PostgreSQL + * `IS NOT DISTINCT FROM` — the two dialects this is reached on (MySQL fences + * after the statement instead; see {@link upsert}). + * + * The target table is the write target's bare PHYSICAL name — the rotation + * shard when one is current, the federated remote table otherwise — because + * that is how both dialects name the INSERT target inside this clause. + */ + static mergeLandsInWrittenOrganization(this: Pick, writeTable: string, column: string): Knex.Raw { + const table = this.physicalTableByObject[writeTable] ?? writeTable; + const notDistinct = this.isPostgres ? 'IS NOT DISTINCT FROM' : 'IS'; + return this.knex.raw(`?? ${notDistinct} ??`, [`${table}.${column}`, `excluded.${column}`]); + } + + /** + * [#8807] After a primary-key-targeted merge on a table that carries a rival + * UNIQUE key: did the statement land on the identity this call supplied? + * + * # Why the absence of the row is an exact verdict, not a heuristic + * + * The statement returned without error, so it either INSERTED (the row now + * carries this id) or MERGED into an existing row. `id` is insert-only on the + * merge path since #8622 — it is stripped from the merge set — so a merge + * never writes this id onto the row it lands on. Therefore: + * + * row with this id exists ⟺ the merge landed on the primary key + * row with this id absent ⟺ it landed on some other UNIQUE key + * + * That is a biconditional, not an inference from a symptom, which is what + * makes this check free of false refusals — the property the ruling's + * excluded blanket option could not have. + * + * # Why the PRIMARY KEY row wins a multi-key collision — measured, not assumed + * + * The biconditional needs one more fact than #8622 to be exact: when the + * incoming row collides on the primary key AND on a rival UNIQUE key at the + * same time, the row carrying our id exists — so "row present" must still mean + * "the statement landed on it", or a pre-existing row with our id would mask a + * merge that went elsewhere. MySQL updates only the FIRST matched index, and + * *which* one that is decides the question. Measured on live MySQL 8.0.46, + * `PRIMARY KEY (id)` + `UNIQUE (email)` + `UNIQUE (tax_id)`, rows + * `R1(id=R1, tax_id=T-1)` and `R2(id=R2, tax_id=T-2)`, inserting + * `(id=R1, tax_id=T-2)` — which collides with R1 on the primary key and with + * R2 on `uniq_tax`: + * + * ``` + * ROW_COUNT() = 2 (an update) + * R1 -> title='MERGED' ← the PRIMARY KEY row won + * R2 -> untouched + * ``` + * + * So the primary key is matched first and the masking case does not arise. + * + * # Tenant scope — applied, and scoped to the tenant the row was WRITTEN under + * + * The read routes through {@link applyTenantScope} like every other read door + * in this class (`check:tenant-chokepoint` asserts exactly that, on a bound + * builder). The subtlety is *which* tenant to scope to, and using the caller's + * active org would be wrong in one real case: + * + * - **Ordinary tenanted call** — nothing supplied a tenant on the row, so + * `injectTenantOnInsert` stamped `options.tenantId` on it. Scoping to the + * written tenant IS scoping to the caller's org: identical to the wall + * every sibling door applies. + * - **Admin writing to a specific tenant via raw row data** — a documented + * authority ({@link injectTenantOnInsert}: "explicit values are never + * overwritten"). The row lands under the tenant the caller named, so a read + * scoped to the caller's *active* org would miss a row that really was + * written and refuse a correct write. Scoping to the written tenant does + * not. + * - **No tenancy field, or no tenant context at all** — `applyTenantScope` + * returns the builder untouched when `tenantId` is empty, by its own + * contract. That is what keeps the lifecycle archiver working: it calls + * `cold.upsert(object, row, ['id'])` with NO options, so this read is + * unscoped there, exactly as before. + * + * The verdict itself is tenant-independent regardless: `id` is the PRIMARY + * KEY, so at most one row in the table can carry it. + * + * ⚠️ [#21185] The first two bullets no longer reach this method: a + * tenant-scoped call on a tenanted object is fenced to its organization, and + * on MySQL {@link assertMergeLandedInWrittenOrganization} runs in this + * check's place, reading under the written tenant and carrying #8807's + * verdict with it. What still arrives here is the third bullet — and there + * the written tenant must NOT become a scope: the tenant column is now + * insert-only, so a payload naming another organization than the stored + * row's is a merge that keeps the row's organization (measured on MariaDB + * 10.11 before this guard: the read scoped to the payload's tenant missed the + * merged row and answered this method's cross-row refusal for a merge that + * landed on the supplied id). So the written tenant is used only under a + * tenant context, and a call without one reads unscoped. + * + * # The write target, not the object + * + * A rotation-sharded write lands in the current shard, so that is where the + * row must be looked for. Reading the unsharded name would report every + * rotated write as a cross-row merge. + */ + static async assertMergeLandedOnSuppliedIdentity(this: Pick, object: string, writeTable: string, id: string | number, rivals: PhysicalIndex[], writtenTenant: unknown, options?: DriverOptions): Promise { const builder = this.getBuilder(writeTable, options); // Scoped to the tenant this row was written under (see the docblock): the // caller's org on an ordinary call, the explicitly named one on an admin @@ -177,8 +450,62 @@ export async function assertMergeLandedOnSuppliedIdentity(this: Pick, object: string, writeTable: string, mergeKeys: string[], matchOn: Record, sent: Record, guard: { column: string; value: unknown }, identity: { id: string | number; rivals: PhysicalIndex[] } | null, options?: DriverOptions): Promise { + /** + * [#21185] MySQL: after a tenant-scoped upsert's statement, did it land on a + * row of the organization the row is WRITTEN under? Runs inside the same + * transaction as the statement, so its throw rolls the write back. + * + * # Why MySQL needs a check where the other dialects need none + * + * SQLite and PostgreSQL fence the merge leg inside the statement + * (`ON CONFLICT … DO UPDATE SET … WHERE` the stored tenant column equals the + * written one — see {@link upsert}), so a row of another organization is + * never written at all. MySQL's `ON DUPLICATE KEY UPDATE` takes no `WHERE` + * (knex refuses to compile one: `.onConflict().merge().where() is not + * supported for mysql`). This is #8807's mechanism, reused as ruled: the + * statement and a read of the landed row run as one unit of work, and the + * read's failure undoes the write. + * + * # The read, and why its absence is exact + * + * The landed row is looked up by the values it matched on (`matchOn`, the + * conflict-key values the statement sent — or `id` when one of them is + * empty, the same choice the read-back makes), under the written tenant + * EXACTLY: {@link applyTenantScope} for the chokepoint, plus equality on the + * tenant column, which closes that scope's NULL-organization arm. The tenant + * column is insert-only ({@link insertOnlyUpsertColumns}), so a merge never + * writes the written tenant onto the row it lands on. Therefore a row + * matching those values under the written tenant exists exactly when the + * statement inserted there or merged into a row that was already there; its + * absence means the merge landed on a row of another organization, or on a + * row with none. + * + * # When #8807's check is owed too (`rivals` given) + * + * A primary-key target on a table carrying a rival UNIQUE key. `matchOn` is + * then the supplied `id`, so a found row is also #8807's verdict — the merge + * landed on the supplied identity — and that check is subsumed. A MISSING row + * has two causes the caller must be told apart, because they are two + * different refusals: + * + * - the merge landed through a rival key on a row of the WRITTEN + * organization — #8807's condition, answered with its own sentence + * ({@link refuseCrossRowIdentityMerge}), exactly as before this card; + * - it landed on a row of another organization — on the primary key (that + * row wins a multi-key collision, measured for #8807) or through a rival + * key — answered `UNIQUE_VIOLATION`. + * + * They are told apart by reading each rival key's sent values under the + * written tenant: a merge through that key left the row carrying them, in + * the written organization. Only a rival whose every column is in the + * payload can be read this way (a hash-shadow carrier's generated column is + * not); when none can, the answer is `UNIQUE_VIOLATION` — a refusal either + * way, and the write is rolled back either way. + * + * ⛔ Nothing read here reaches the caller: the refusal names no organization + * and no value of the row it landed on. + */ + static async assertMergeLandedInWrittenOrganization(this: Pick, object: string, writeTable: string, mergeKeys: string[], matchOn: Record, sent: Record, guard: { column: string; value: unknown }, identity: { id: string | number; rivals: PhysicalIndex[] } | null, options?: DriverOptions): Promise { // The tenant scope of a ONE-row probe: the written tenant, with // `tenantIds` dropped for #8807's reason (the group-union posture widens a // READ to a membership set; this asks about one organization). @@ -207,8 +534,8 @@ export async function assertMergeLandedInWrittenOrganization(this: Pick, object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { + /** The body of `SqlDriver#upsert`; the method's docblock there is its contract. */ + static async upsert(this: Pick, object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { const { _id, ...rest } = data; const toUpsert = { ...rest }; @@ -584,3 +911,24 @@ export async function upsert(this: Pick, object: string, id: string | number, options?: DriverOptions): Promise { + this.auditMissingTenant(object, 'delete', options); + const rotationShards = this.rotationShardsOf(object); + if (rotationShards) { + // The row lives in exactly one shard — probe newest-first. + for (const shard of rotationShards) { + const builder = this.getBuilder(shard, options).where('id', id); + this.applyTenantScope(builder, object, options); + const count = await builder.delete(); + if (count > 0) return true; + } + return false; + } + const builder = this.getBuilder(object, options).where('id', id); + this.applyTenantScope(builder, object, options); + const count = await builder.delete(); + return count > 0; + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts b/packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts index f72f424e304..3bea330c8d5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts @@ -5,22 +5,27 @@ * bodies of `formatInput`, `formatOutput` moved out of `sql-driver.ts` whole by * `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link SqliteSerialisationFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 SqliteSerialisationHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * The bodies reach the class through {@link SqliteSerialisationHost}: exactly + * the `SqlDriver` members they read, private ones included. Every stub hands + * its body, by a `.call`, the same host: `sqliteSerialisationHost` 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. + * 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 { @@ -29,10 +34,11 @@ import { import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link SqliteSerialisationFacet} 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 SqliteSerialisationHost { readonly booleanFields: SqlDriver['booleanFields']; @@ -53,8 +59,10 @@ export interface SqliteSerialisationHost { readonly undeclaredAuditTimestampFields: SqlDriver['undeclaredAuditTimestampFields']; } -/** The body of `SqlDriver#formatInput`; the method's docblock there is its contract. */ -export function formatInput(this: Pick, object: string, data: any): any { +/** The bodies of the "SQLite serialisation" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class SqliteSerialisationFacet { + /** The body of `SqlDriver#formatInput`; the method's docblock there is its contract. */ + static formatInput(this: Pick, object: string, data: any): any { let copy: any = data; let copied = false; @@ -262,8 +270,8 @@ export function formatInput(this: Pick, object: string, data: any): any { + /** The body of `SqlDriver#formatOutput`; the method's docblock there is its contract. */ + static formatOutput(this: Pick, object: string, data: any): any { if (!data) return data; // External columnMap (ADR-0015): rename physical remote-column keys to local @@ -529,3 +537,4 @@ export function formatOutput(this: Pick, transaction: unknown): void { + const key = transaction as object; + if (key && this.openTransactions.has(key)) { + this.openTransactions.delete(key); + this.activeTransactions = Math.max(0, this.activeTransactions - 1); + } + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts b/packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts index 5d13314c417..282cd95c4f6 100644 --- a/packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts +++ b/packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts @@ -5,22 +5,27 @@ * bodies of `buildWindowFunction` moved out of `sql-driver.ts` whole by * `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link WindowFunctionBuilderFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 WindowFunctionBuilderHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * The bodies reach the class through {@link WindowFunctionBuilderHost}: exactly + * the `SqlDriver` members they read, private ones included. Every stub hands + * its body, by a `.call`, the same host: `windowFunctionBuilderHost` 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. + * 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 { @@ -30,18 +35,21 @@ import type { SqlWindowFunctionSpec } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link WindowFunctionBuilderFacet} 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 WindowFunctionBuilderHost { readonly knex: SqlDriver['knex']; mapSortField: SqlDriver['mapSortField']; } -/** The body of `SqlDriver#buildWindowFunction`; the method's docblock there is its contract. */ -export function buildWindowFunction(this: Pick, spec: SqlWindowFunctionSpec): string { +/** The bodies of the "Window function builder" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class WindowFunctionBuilderFacet { + /** The body of `SqlDriver#buildWindowFunction`; the method's docblock there is its contract. */ + static buildWindowFunction(this: Pick, spec: SqlWindowFunctionSpec): string { const func = SQL_WINDOW_FUNCTIONS.get(String(spec.function).toLowerCase()); if (func === undefined) { throw invalidWindowFunctionQueryError( @@ -78,3 +86,4 @@ export function buildWindowFunction(this: Pick 0 ? ` OVER (${overParts.join(' ')})` : ` OVER ()`; return sql; } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-window-functions.ts b/packages/drivers/driver-sql/src/sql-driver-window-functions.ts index b1a2a8639fb..0d7b870bc39 100644 --- a/packages/drivers/driver-sql/src/sql-driver-window-functions.ts +++ b/packages/drivers/driver-sql/src/sql-driver-window-functions.ts @@ -5,22 +5,27 @@ * `findWithWindowFunctions` moved out of `sql-driver.ts` whole by * `scripts/codemod-split-sql-driver.mjs`. * - * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * A MOVE, not a redesign. {@link WindowFunctionsFacet} 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 with its docblock and - * signature, so the published `dist/*.d.ts`, an instance spy and a subclass - * override are what they were; the docblock there is the contract. + * 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 WindowFunctionsHost}: exactly the - * `SqlDriver` members they read. Each method hands its body one literal of the - * members that body reads, plus a `.call`: methods go as closures that call - * them on the driver at call time (the builder factory as a bound reference), - * 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. + * `SqlDriver` members they read, private ones included. Every stub hands its + * body, by a `.call`, the same host: `windowFunctionsHost` 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. + * 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'; @@ -28,10 +33,11 @@ import type { SqlWindowFunctionQuery } from './sql-driver-top-level.js'; import type { SqlDriver } from './sql-driver.js'; /** - * The `SqlDriver` members the functions below 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 `SqlDriver` members the bodies of {@link WindowFunctionsFacet} 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 WindowFunctionsHost { aliasIdentifierSql: SqlDriver['aliasIdentifierSql']; @@ -44,8 +50,10 @@ export interface WindowFunctionsHost { mapSortField: SqlDriver['mapSortField']; } -/** The body of `SqlDriver#findWithWindowFunctions`; the method's docblock there is its contract. */ -export async function findWithWindowFunctions(this: Pick, object: string, query: SqlWindowFunctionQuery, options?: DriverOptions): Promise { +/** The bodies of the "Window Functions" section of `SqlDriver`: a stateless facet, called by the class's stubs. */ +export class WindowFunctionsFacet { + /** The body of `SqlDriver#findWithWindowFunctions`; the method's docblock there is its contract. */ + static async findWithWindowFunctions(this: Pick, object: string, query: SqlWindowFunctionQuery, options?: DriverOptions): Promise { const builder = this.getBuilder(object, options); // ROWS, so this is the read-side wall itself — not a consistency tidy-up // (#6792). This door returned every tenant's rows to a caller that passed @@ -148,3 +156,4 @@ export async function findWithWindowFunctions(this: Pick([ */ const MEDIA_ID_VARCHAR_CHARS = 2048; +// [#29520 codemod] The statics of `SqlDriver` its facet modules read, private ones included, as +// the class declares them (an indexed access on the class). The static block at the end of +// the class fills `SqlDriverStatics` with forwarders: a method is called on the class at +// call time, a field or accessor is read when the body reads it. A facet module imports +// `SqlDriverStatics` AS `SqlDriver`, so each moved body reads `SqlDriver.x` unedited; +// `index.ts` re-exports neither, so neither reaches the package. +export interface SqlDriverStaticsHost { + readonly ASSUMED_BYTES_PER_CHAR: (typeof SqlDriver)['ASSUMED_BYTES_PER_CHAR']; + readonly DEFAULT_CONNECT_TIMEOUT_MS: (typeof SqlDriver)['DEFAULT_CONNECT_TIMEOUT_MS']; + readonly DEFAULT_CREATE_TIMEOUT_MS: (typeof SqlDriver)['DEFAULT_CREATE_TIMEOUT_MS']; + readonly DEFAULT_STRING_VARCHAR_CHARS: (typeof SqlDriver)['DEFAULT_STRING_VARCHAR_CHARS']; + readonly DIALECT_CONNECT_TIMEOUT: (typeof SqlDriver)['DIALECT_CONNECT_TIMEOUT']; + readonly MAX_KEYABLE_VARCHAR_CHARS: (typeof SqlDriver)['MAX_KEYABLE_VARCHAR_CHARS']; + readonly MAX_VARCHAR_CHARS: (typeof SqlDriver)['MAX_VARCHAR_CHARS']; + readonly MYSQL_EMIT_CLIENTS: (typeof SqlDriver)['MYSQL_EMIT_CLIENTS']; + readonly MYSQL_ROW_BYTE_BUDGET: (typeof SqlDriver)['MYSQL_ROW_BYTE_BUDGET']; + readonly MYSQL_TEXT_TEMPORAL_TYPES: (typeof SqlDriver)['MYSQL_TEXT_TEMPORAL_TYPES']; + readonly PG_OID_DATE: (typeof SqlDriver)['PG_OID_DATE']; + readonly PG_OID_DATE_ARRAY: (typeof SqlDriver)['PG_OID_DATE_ARRAY']; + readonly PG_OID_TEXT_ARRAY: (typeof SqlDriver)['PG_OID_TEXT_ARRAY']; + readonly POSTGRES_EMIT_CLIENTS: (typeof SqlDriver)['POSTGRES_EMIT_CLIENTS']; + readonly POSTGRES_WIRE_CLIENTS: (typeof SqlDriver)['POSTGRES_WIRE_CLIENTS']; + readonly POSTGRES_WIRE_ONLY_CLIENTS: (typeof SqlDriver)['POSTGRES_WIRE_ONLY_CLIENTS']; + readonly SQLITE_AUTO_VACUUM_INCREMENTAL: (typeof SqlDriver)['SQLITE_AUTO_VACUUM_INCREMENTAL']; + readonly SQLITE_EMIT_CLIENTS: (typeof SqlDriver)['SQLITE_EMIT_CLIENTS']; + readonly SQLITE_FILE_REFUSALS: (typeof SqlDriver)['SQLITE_FILE_REFUSALS']; + readonly SQLITE_JOURNAL_MODE_ENV: (typeof SqlDriver)['SQLITE_JOURNAL_MODE_ENV']; + readonly autoVacuumOf: (typeof SqlDriver)['autoVacuumOf']; + readonly clientSpelling: (typeof SqlDriver)['clientSpelling']; + readonly emissionClients: (typeof SqlDriver)['emissionClients']; + readonly hashShadowColumnFor: (typeof SqlDriver)['hashShadowColumnFor']; + readonly isSqliteBusyError: (typeof SqlDriver)['isSqliteBusyError']; + readonly journalModeOf: (typeof SqlDriver)['journalModeOf']; + readonly varcharPackLength: (typeof SqlDriver)['varcharPackLength']; + readonly withMysqlCalendarDayAsText: (typeof SqlDriver)['withMysqlCalendarDayAsText']; + readonly withPostgresCalendarDayAsText: (typeof SqlDriver)['withPostgresCalendarDayAsText']; + readonly withUtcSession: (typeof SqlDriver)['withUtcSession']; +} +export const SqlDriverStatics = {} as SqlDriverStaticsHost; + export class SqlDriver implements IDataDriver { // IDataDriver metadata public readonly name: string = 'com.objectstack.driver.sql'; @@ -968,24 +1020,13 @@ export class SqlDriver implements IDataDriver { * way). */ protected static emissionClients(): string[] { - return [ - ...SqlDriver.SQLITE_EMIT_CLIENTS, - ...SqlDriver.POSTGRES_EMIT_CLIENTS, - ...SqlDriver.MYSQL_EMIT_CLIENTS, - ].sort(); + // [#29520 codemod] The body moved to `sql-driver-class-head.ts`. + return ClassHeadFacet.emissionClients(); } - /** - * The configured client as a STRING, or `''`. - * - * `Knex.Config['client']` also admits a Client CONSTRUCTOR (knex's documented - * bring-your-own-dialect hatch). Such a value names no spelling in any table - * here, and `''` is how it says so — the same answer the literal comparisons - * this replaces gave, without stringifying a class to get there. - */ private static clientSpelling(config: unknown): string { - const c = (config as { client?: unknown } | null | undefined)?.client; - return typeof c === 'string' ? c : ''; + // [#29520 codemod] The body moved to `sql-driver-class-head.ts`, its docblock with it. + return ClassHeadFacet.clientSpelling(config); } /** @@ -1121,7 +1162,8 @@ export class SqlDriver implements IDataDriver { * through `??`; only the name after `as` is one identifier by definition. */ protected aliasIdentifierSql(alias: string): string { - return (this.knex.client as { wrapIdentifier(value: string): string }).wrapIdentifier(String(alias)); + // [#29520 codemod] The body moved to `sql-driver-class-head.ts` (see `ClassHeadHost`). + return ClassHeadFacet.aliasIdentifierSql.call(classHeadHost.of(this), alias); } protected get dateGranularityCapabilities(): Record { @@ -1227,8 +1269,8 @@ export class SqlDriver implements IDataDriver { * member of the `IDataDriver` contract. */ public dateBucketSql(objectName: string, field: string, granularity: DateGranularityValue): string | null { - const bucket = this.buildDateBucketExpr(field, granularity, objectName); - return bucket ? this.knex.raw(bucket.sql, bucket.bindings).toQuery() : null; + // [#29520 codemod] The body moved to `sql-driver-class-head.ts` (see `ClassHeadHost`). + return ClassHeadFacet.dateBucketSql.call(classHeadHost.of(this), objectName, field, granularity); } /** @@ -1354,42 +1396,12 @@ export class SqlDriver implements IDataDriver { return this.openedEmptyInMemory; } - /** - * Apply {@link SqliteAbsentFileMode} to the Knex config, redirecting a - * missing SQLite file to `:memory:` before Knex ever opens it. - * - * Has to happen HERE rather than in `connect()`: the filename is baked into - * the Knex instance at construction, and better-sqlite3 opens (and therefore - * creates) the file on the first statement — which for this driver is the - * `PRAGMA auto_vacuum` in {@link connect}. - * - * A file the judgement refuses — one that can be neither opened nor created - * (#24961) — throws its `SqliteDatabaseFileError` out of the constructor, so - * no Knex instance is ever built over it. - * - * Only a SQLite client is asked (#24961). A server dialect's `connection` - * string is a URL, never a filename: handed to the judgement it read as an - * absent file, and a `pg` driver given this option had its connection string - * rewritten to `:memory:` — against {@link SqlDriverConfig.sqliteAbsentFile}'s - * "ignored for non-SQLite dialects". - */ private knexConfigFor( knexConfig: Knex.Config, mode: SqliteAbsentFileMode | undefined, ): Knex.Config { - if (mode !== 'empty-in-memory' || !this.isSqlite) return knexConfig; - const conn = (knexConfig as { connection?: unknown }).connection; - const declared = typeof conn === 'string' ? conn : (conn as { filename?: unknown })?.filename; - if (typeof declared !== 'string') return knexConfig; - const resolved = resolveSqliteAbsentFileTarget(declared, mode); - if (!resolved.openedEmptyInMemory) return knexConfig; - this.openedEmptyInMemory = true; - return { - ...knexConfig, - connection: typeof conn === 'string' - ? resolved.filename - : { ...(conn as object), filename: resolved.filename }, - } as Knex.Config; + // [#29520 codemod] The body moved to `sql-driver-class-head.ts`, its docblock with it (see `ClassHeadHost`). + return ClassHeadFacet.knexConfigFor.call(classHeadHost.of(this), knexConfig, mode); } /** @@ -1472,109 +1484,13 @@ export class SqlDriver implements IDataDriver { ]); private static withConnectBound(knexConfig: Record): Record { - const pool = knexConfig.pool as Record | undefined; - const bounded: Record = - pool?.createTimeoutMillis === undefined - ? { - ...knexConfig, - pool: { ...(pool ?? {}), createTimeoutMillis: SqlDriver.DEFAULT_CREATE_TIMEOUT_MS }, - } - : { ...knexConfig }; // host chose its own bound — respect it - - // `!dialect` — sqlite, or a client with no connect-timeout knob — means - // there is no TIMEOUT to inject. It deliberately does not skip the session - // pins below: those answer a different question (what does a value MEAN on - // this connection), and the two lists are not the same list. - // - // ⚠️ The worked example that used to stand here was RETIRED by #11784, - // which gave `redshift` the connect-timeout row it was missing. Until then - // `redshift` was in {@link POSTGRES_WIRE_CLIENTS} (so it needed #11389's - // calendar-day pin) yet absent from {@link DIALECT_CONNECT_TIMEOUT} — so a - // `return` placed at this point silently opted it out of a fix it needed. - // That was measured, not hypothetical, which is why the example is recorded - // here rather than dropped. - // - // The hazard outlives the example. Every client needing a session pin - // happens to have a timeout row TODAY; that is current membership, not a - // property either table promises. The next pg-wire client added without a - // connect-timeout knob restores the exact bug — and it would fail the way - // this one did, in silence, with every test green. So: no `return` here, - // and the two tables stay separate. The tripwire that goes red the moment - // those memberships diverge again lives in - // `sql-driver-11389-date-tz-skew.test.ts`. - const dialect = SqlDriver.DIALECT_CONNECT_TIMEOUT[String(knexConfig.client ?? '')]; - if (dialect) { - const conn = knexConfig.connection; - if (typeof conn === 'string') { - // The URL must move into the dialect's own URL slot so the timeout can - // ride alongside it. Verified for both dialects: the connection attempt - // still goes to the URL's host/port, `?sslmode=` is still honoured. - bounded.connection = { - [dialect.urlKey]: conn, - [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS, - }; - } else if (conn && typeof conn === 'object' && (conn as any)[dialect.key] === undefined) { - bounded.connection = { ...(conn as object), [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS }; - } - // A function-valued `connection` (knex's per-acquire provider) is left - // alone: the host is building each connection itself and owns its timeouts. - } - return SqlDriver.withPostgresCalendarDayAsText( - SqlDriver.withMysqlCalendarDayAsText(SqlDriver.withUtcSession(bounded)), - ); + // [#29520 codemod] The body moved to `sql-driver-class-head.ts`. + return ClassHeadFacet.withConnectBound(knexConfig); } - /** - * Pin a MySQL connection to UTC, in both directions (#3942). - * - * MySQL is the one supported dialect where the SESSION timezone participates - * in what a datetime means, on two independent layers, and both default to - * something machine-dependent: - * - * - **mysql2** (`connection.timezone`, default `'local'`) decides how a bound - * JS `Date` is rendered and how a returned `DATETIME` string is parsed back. - * Left at `'local'`, two app servers in different zones write the same - * instant as different values, and read the same row as different instants. - * - **The server** (`@@session.time_zone`, default `SYSTEM`) decides how a - * zone-naive literal is interpreted for a `TIMESTAMP` column, and what - * `CURRENT_TIMESTAMP` renders. Measured at 8 hours off on a server - * configured `+08:00`. - * - * Setting both to UTC makes the wall clock the driver writes *be* the instant, - * which is what {@link mysqlDatetimeLiteral} relies on — and it keeps legacy - * `TIMESTAMP` columns correct too, so a deployment stays right whether or not - * the `DATETIME(3)` migration has run. - * - * A host that set either one explicitly is left alone; an existing - * `pool.afterCreate` is chained rather than replaced, since it is a documented - * knex extension point the host may already be using. - */ private static withUtcSession(knexConfig: Record): Record { - // The MySQL identity question again, asked from a static: the same set - // `isMysql` reads, not a fourth hand-written copy of it (#11550). - if (!SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(knexConfig))) return knexConfig; - - const out: Record = { ...knexConfig }; - const conn = out.connection; - if (conn && typeof conn === 'object' && (conn as any).timezone === undefined) { - out.connection = { ...(conn as object), timezone: 'Z' }; - } - - const pool = (out.pool ?? {}) as Record; - const hostAfterCreate = pool.afterCreate as - | ((conn: unknown, done: (err?: unknown) => void) => void) - | undefined; - out.pool = { - ...pool, - afterCreate(connection: any, done: (err?: unknown, conn?: unknown) => void) { - connection.query(`SET time_zone = '+00:00'`, (err: unknown) => { - if (err) return done(err); - if (!hostAfterCreate) return done(undefined, connection); - hostAfterCreate(connection, (hostErr?: unknown) => done(hostErr, connection)); - }); - }, - }; - return out; + // [#29520 codemod] The body moved to `sql-driver-class-head.ts`, its docblock with it. + return ClassHeadFacet.withUtcSession(knexConfig); } /** @@ -1585,64 +1501,9 @@ export class SqlDriver implements IDataDriver { */ private static readonly MYSQL_TEXT_TEMPORAL_TYPES: readonly string[] = ['DATE']; - /** - * Keep a MySQL `DATE` a calendar-day STRING, never a JS `Date` (#20280). - * - * The MySQL counterpart of {@link withPostgresCalendarDayAsText}: the wire - * form of a `DATE` IS `YYYY-MM-DD`, so the driver asks mysql2 for that text - * (`dateStrings: ['DATE']`) and presents it through {@link toDateOnly} — - * `@objectstack/core`'s `temporalStorageForm`, the rule the write and - * `where` paths already apply. A `date` column then reaches the read doors - * as TEXT on every dialect. - * - * ## The measurement - * - * mysql2 3.23's `Packet#parseDate` rebuilds a `DATE` from its three numbers: - * `new Date(Date.UTC(y, m - 1, d))` under the `timezone: 'Z'` pin of - * {@link withUtcSession}, `new Date(y, m - 1, d)` under the default - * `'local'`. Both constructors read a year from 0 to 99 as 1900 + year. - * Measured on MySQL 8.0.46 (server `time_zone='+08:00'`) through `find()`: - * - * | stored (`CAST(d AS CHAR)`) | mysql2 materialised | presented before | presented now | - * |---|---|---|---| - * | `0009-03-04` | `1909-03-04T00:00:00.000Z` | `1909-03-04` | `0009-03-04` | - * | `0099-03-04` | `1999-03-04T00:00:00.000Z` | `1999-03-04` | `0099-03-04` | - * | `0999-06-15` | `0999-06-15T00:00:00.000Z` | `0999-06-15` | `0999-06-15` | - * | `2026-03-04` | `2026-03-04T00:00:00.000Z` | `2026-03-04` | `2026-03-04` | - * - * The write was right and the read was wrong: `where d $eq '0009-03-04'` - * found the row and presented `1909-03-04`. A year from 1000 to 9999 presents - * exactly what it did. A zero day (`0000-00-00`, storable only with - * `NO_ZERO_DATE` off) presents as that text, where mysql2 invented - * `1899-11-30`. - * - * ## Why `DATE` and not `DATETIME` - * - * `dateStrings` also takes `'DATETIME'`, and a `DATETIME` in years 0..99 is - * misread too: `parseDateTime` hands `'0009-03-04 10:00:00.000Z'` to V8's - * non-ISO `Date` parser, which answers 2004-09-03. But a `DATETIME` is an - * instant, and ADR-0053 D-F2 keeps the client parser's `Date` for an instant, - * folded to text only at the driver's own read doors — text at the client - * parser is the option that ADR did not take. That half is not decided here. - * - * The other measured remedy, a `'+00:00'` zone, takes mysql2's padded - * string-constructor arm for a `DATE` but keeps the `Date`, and moves the - * zone every bound `Date` and every `DATETIME` is rendered in. The text is - * the narrower change, and it is how PostgreSQL already reads a day. - * - * A host that set `dateStrings` itself is left alone, as - * {@link withUtcSession} leaves an explicit `timezone`; so is a - * function-valued `connection`, which the host builds per acquire. - */ private static withMysqlCalendarDayAsText(knexConfig: Record): Record { - if (!SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(knexConfig))) return knexConfig; - - const conn = knexConfig.connection; - if (!conn || typeof conn !== 'object' || (conn as any).dateStrings !== undefined) return knexConfig; - return { - ...knexConfig, - connection: { ...(conn as object), dateStrings: [...SqlDriver.MYSQL_TEXT_TEMPORAL_TYPES] }, - }; + // [#29520 codemod] The body moved to `sql-driver-class-head.ts`, its docblock with it. + return ClassHeadFacet.withMysqlCalendarDayAsText(knexConfig); } /** @@ -1686,161 +1547,16 @@ export class SqlDriver implements IDataDriver { */ private static readonly PG_OID_TEXT_ARRAY = 1009; - /** - * Keep a Postgres `date` a calendar-day STRING, never a JS `Date` (#11389). - * - * This is the Postgres counterpart of {@link withUtcSession}, and it exists - * for the same reason: the value a driver materialises must not depend on - * which machine's clock the Node process happens to be running. - * - * ## The measurement - * - * `node-postgres` parses OID 1082 with `new Date(y, m - 1, d)` — **local** - * midnight. `SqlDriver#toDateOnly` reads a `Date` with UTC components (its - * documented contract, see there), so on a process east of UTC the two - * disagree by a calendar day. Measured on PostgreSQL 16, one stored row - * `d = '2026-08-24'`, only the process `TZ` changed: - * - * | process TZ | pg materialises | `toDateOnly` returned | - * |---|---|---| - * | `UTC` | `2026-08-24T00:00:00.000Z` | `2026-08-24` | - * | `America/New_York` | `2026-08-24T04:00:00.000Z` | `2026-08-24` | - * | `Asia/Shanghai` | `2026-08-23T16:00:00.000Z` | **`2026-08-23`** | - * - * West of UTC the UTC components happen to name the right day, which is why - * the `Temporal Conformance (live PG + MySQL)` job — pinned at - * `TZ=America/New_York` — was green throughout. Only an east-of-UTC process - * zone can fail it, so the pin that guards this sweeps the process zone. - * - * ## Why the parser and not the reader - * - * The alternative — read local components in `toDateOnly` — was measured and - * rejected: that helper is shared by the READ path (`formatOutput`, - * `presentReadValue`), the WRITE path (`formatInput`) and the FILTER path - * (`coerceFilterValue`), and the `Date`s they receive are on different - * clocks. A caller's `new Date('2026-08-24')` is UTC midnight, so under - * `TZ=America/New_York` local components read it as `2026-08-23` — the same - * one-day error in the mirror direction, moved onto the write and filter - * paths. Fixing it at the parser leaves exactly one clock in play, because - * the driver then never produces a `Date` for a `date` column at all — which - * is already how SQLite behaves (TEXT round-trip) and, via - * {@link withMysqlCalendarDayAsText}, how mysql2 behaves. (Before that, - * {@link withUtcSession}'s `timezone: 'Z'` kept mysql2's `Date` on the one - * UTC clock, but read a year below 100 a century late.) - * - * `pool.afterCreate` is the hook rather than a `pg.types.setTypeParser` - * call because `setTypeParser` mutates the pg-types registry **process - * wide**, which would reach every other pg client in the host application. - * A parser registered on the connection is scoped to the pools this driver - * opened; a host's own `pg` clients keep the stock behaviour. `pg` is an - * optional peer dependency and is never imported here — `setTypeParser` / - * `getTypeParser` are read off the `pg.Client` knex hands the hook. - * - * `timestamptz` / `timestamp` are deliberately untouched here: those are - * instants, and a `Date` is the right materialisation for them at the - * CLIENT layer, where the wire text is parsed. It is not, however, what the - * driver hands out of its read doors: [ADR-0053 D-F1/D-F2] (#13973) fold that - * `Date` to the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text in `formatOutput` - * / `presentReadValue`, so every dialect presents the instant the way - * SQLite always has. This comment used to add that "`Field.datetime` - * depends on" the `Date` materialisation; #13973 checked that on the tree - * and it did not hold — nothing on the read path consumed the `Date` (the - * SQLite-gated `formatOutput` never touched a datetime column on Postgres), - * and every in-repo reader of the value already accepted the text form. - * Canonicalising at the driver's own read boundary rather than at this - * parser is the ruled shape: one clock stays in play here for `date`, the - * instant types keep their stock parser, and no host `pg` client is touched. - * - * A host's existing `pool.afterCreate` is chained rather than replaced, - * exactly as in {@link withUtcSession}. - */ private static withPostgresCalendarDayAsText( knexConfig: Record, ): Record { - if (!SqlDriver.POSTGRES_WIRE_CLIENTS.has(String(knexConfig.client ?? ''))) return knexConfig; - - const out: Record = { ...knexConfig }; - const pool = (out.pool ?? {}) as Record; - const hostAfterCreate = pool.afterCreate as - | ((conn: unknown, done: (err?: unknown) => void) => void) - | undefined; - out.pool = { - ...pool, - afterCreate(connection: any, done: (err?: unknown, conn?: unknown) => void) { - const chain = (): void => { - if (!hostAfterCreate) return done(undefined, connection); - hostAfterCreate(connection, (hostErr?: unknown) => done(hostErr, connection)); - }; - // Not a `pg.Client` after all (a stub, a future knex shape): leave the - // connection exactly as it was rather than failing the acquire. - if ( - typeof connection?.setTypeParser !== 'function' || - typeof connection?.getTypeParser !== 'function' - ) { - return chain(); - } - const parseTextArray = connection.getTypeParser(SqlDriver.PG_OID_TEXT_ARRAY, 'text'); - // The wire form of `date` IS `YYYY-MM-DD`; handing it back verbatim is - // both the fix and the whole parser. - connection.setTypeParser(SqlDriver.PG_OID_DATE, (text: string) => text); - connection.setTypeParser(SqlDriver.PG_OID_DATE_ARRAY, parseTextArray); - chain(); - }, - }; - return out; + // [#29520 codemod] The body moved to `sql-driver-class-head.ts`, its docblock with it. + return ClassHeadFacet.withPostgresCalendarDayAsText(knexConfig); } - /** - * Per-request SQL query timing (perf-tuning mode). Correlates knex's - * `query` → `query-response` / `query-error` events by `__knexQueryUid` and - * folds each query's wall time into the ambient request collector's `db` - * aggregate — one `Server-Timing` member carrying total DB time **and** a - * query count, the number most useful for spotting N sequential round-trips - * in DevTools → Network → Timing. - * - * Attribution is captured at `query` time, which runs inside the initiating - * request's `AsyncLocalStorage` scope (knex emits it synchronously from the - * runner after connection acquisition, still on the awaited call chain), so - * concurrent requests never cross-attribute. When perf-tuning is off, - * {@link currentPerfTiming} is `undefined` and the listener returns at once — - * nothing is tracked and the map stays empty, so the cost is a single ALS - * lookup per query. - * - * The aggregate records only durations and a count — never SQL text — so the - * `Server-Timing` header can be surfaced without leaking query shapes to - * non-admins. When an admin opts into DETAIL mode (`X-OS-Debug-Timing: json`, - * admin-gated), each query's PARAMETRIZED statement (knex's `q.sql`, which - * carries `?` placeholders — the bindings live separately and are NEVER - * recorded) is additionally captured so the admin-only detail payload can list - * the slowest queries by shape. Literal row values never enter the collector. - */ private installQueryTiming(): void { - // uid → { start, collector }, populated only while a collector is active, - // and always removed when the query settles (bounded by in-flight queries). - const inflight = new Map(); - this.knex.on('query', (q: any) => { - const timing = currentPerfTiming(); - if (!timing) return; - const uid = q?.__knexQueryUid; - if (typeof uid === 'string') inflight.set(uid, { t0: perfNow(), timing }); - }); - const settle = (q: any): void => { - const uid = q?.__knexQueryUid; - if (typeof uid !== 'string') return; - const rec = inflight.get(uid); - if (!rec) return; - inflight.delete(uid); - const dur = perfNow() - rec.t0; - rec.timing.count('db', dur, 'queries'); - // Admin-gated detail mode: keep the query SHAPE (parametrized SQL, no - // bindings) so the slowest queries can be surfaced. `recordDetail` is a - // no-op unless detail capture is on, so this is free on the normal path. - if (rec.timing.detailEnabled && typeof q?.sql === 'string') { - rec.timing.recordDetail('db', q.sql, dur); - } - }; - this.knex.on('query-response', (_response: any, q: any) => settle(q)); - this.knex.on('query-error', (_error: any, q: any) => settle(q)); + // [#29520 codemod] The body moved to `sql-driver-class-head.ts`, its docblock with it (see `ClassHeadHost`). + return ClassHeadFacet.installQueryTiming.call(classHeadHost.of(this)); } /** @@ -1856,13 +1572,8 @@ export class SqlDriver implements IDataDriver { * refusal fires only where DDL would otherwise have PROCEEDED. */ protected assertSchemaMutable(operation: string): void { - if (this.schemaMode !== 'managed') { - throw new ExternalSchemaModeViolationError( - `DDL operation '${operation}' is forbidden: datasource schemaMode='${this.schemaMode}'. ` + - `ObjectStack never mutates the schema of an external database.`, - ); - } - this.assertDialectEmits(operation); + // [#29520 codemod] The body moved to `sql-driver-class-head.ts` (see `ClassHeadHost`). + return ClassHeadFacet.assertSchemaMutable.call(classHeadHost.of(this), operation); } /** @@ -1899,9 +1610,8 @@ export class SqlDriver implements IDataDriver { * writes and connection handling on these databases are untouched. */ protected assertDialectEmits(operation: string): void { - const client = SqlDriver.clientSpelling(this.config); - if (!SqlDriver.POSTGRES_WIRE_ONLY_CLIENTS.has(client)) return; - throw new UnsupportedDialectEmissionError(client, operation, SqlDriver.emissionClients()); + // [#29520 codemod] The body moved to `sql-driver-class-head.ts` (see `ClassHeadHost`). + return ClassHeadFacet.assertDialectEmits.call(classHeadHost.of(this), operation); } // =================================== @@ -1910,160 +1620,17 @@ export class SqlDriver implements IDataDriver { connect(): Promise { // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts` (see `LifecycleHost`). - const readIsSqlite = () => this.isSqlite; - const host: ThisParameterType = { - applyAutoVacuumIncremental: (...a) => this.applyAutoVacuumIncremental(...a), - applySqliteJournalMode: (...a) => this.applySqliteJournalMode(...a), - ensureDatabaseExists: (...a) => this.ensureDatabaseExists(...a), - get isSqlite() { return readIsSqlite(); }, - releaseRefusedSqliteConnection: (...a) => this.releaseRefusedSqliteConnection(...a), - }; - return connect.call(host); + return LifecycleFacet.connect.call(lifecycleHost.of(this)); } - /** - * Close what a refused SQLite `connect()` opened, before the refusal leaves - * it (#25084). - * - * A file refused by its first statement ({@link SQLITE_FILE_REFUSALS}) was - * opened to answer it: the pool keeps that better-sqlite3 connection, and for - * a file whose header says WAL, SQLite keeps its `-shm` and an empty `-wal` - * beside it for as long as the connection is open. No caller is owed that - * handle — the connect failed — and a caller that does not `disconnect()` a - * handle whose `connect()` rejected (the datasource connection service's - * degraded path) used to keep it for the life of the process. - * - * The pool is destroyed and a fresh, empty one put in its place, so the - * instance is left as it was constructed: nothing open, and a later - * `connect()` opens the file again and gives its own answer. Destroying - * alone would not: every later statement would answer "Unable to acquire a - * connection", which carries no code, so a second `connect()` would log it - * and resolve over a datasource that cannot run one query. A host-adopted - * instance is connected again on the same object (`createPrebuiltDriverFactory` - * in `@objectstack/service-datasource`), so that second `connect()` is real. - * - * SQLite only: it is the one dialect whose `connect()` can reject holding a - * connection. A server dialect rejects there only when the connection itself - * could not be made, so its pool holds nothing to release. - * - * Never throws: the refusal the caller is owed is the error `connect()` - * raised, so a release that fails is logged and that error goes on unchanged. - */ - private async releaseRefusedSqliteConnection(): Promise { - try { - await this.knex.destroy(); - this.knex.initialize(); - } catch (e) { - this.logger.warn('Failed to close the SQLite connection a refused connect() opened', e); - } + private releaseRefusedSqliteConnection(): Promise { + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts`, its docblock with it (see `LifecycleHost`). + return LifecycleFacet.releaseRefusedSqliteConnection.call(lifecycleHost.of(this)); } - /** - * SQLite space hygiene (ADR-0057). With the default `auto_vacuum=NONE`, - * freed pages are never returned to the OS — a database that briefly grew - * (e.g. high-frequency telemetry before retention sweeps run) stays at its - * high-water mark forever. INCREMENTAL lets a later `PRAGMA incremental_vacuum` - * (run by the lifecycle Reaper, or manually) reclaim that space without a - * full blocking VACUUM. NOTE: auto_vacuum only changes layout on a *fresh* - * database or after a one-time full VACUUM, so this benefits new dev DBs; - * existing files need a single `VACUUM` to adopt it (`os db clean`). - * Unaffected by the journal mode: an INCREMENTAL database keeps reclaiming - * under WAL. - * - * ## Read first, set only when different (#21734) - * - * The setter is not free on a file that already answers INCREMENTAL: it - * runs a write transaction that stamps the header — the file change counter - * (bytes 24–27) and the version-valid-for number (bytes 92–95) — although - * the mode does not change. So every connect used to leave an - * already-incremental file different from how it found it, a read-only - * `os migrate duplicates` included. Reading `PRAGMA auto_vacuum` is - * byte-neutral (measured on an INCREMENTAL WAL file: md5 unchanged), so the - * setter is issued only when the file does not answer INCREMENTAL: - * - * - an already-incremental file: the read only — nothing is written; - * - a fresh file and `:memory:`: they answer NONE, get the setter, and come - * out INCREMENTAL exactly as before; - * - a legacy NONE file that holds tables: it gets the same setter it always - * got, which leaves it NONE until a `VACUUM` (this method runs none); - * - a FULL file: it gets the setter and moves to INCREMENTAL, as before. - * - * A reply this method cannot read counts as "not INCREMENTAL": an unreadable - * answer costs the old write, never the mode. - * - * @returns whether the connection answered. The read is the first statement - * any connection runs, so `false` says the connection itself is not answering — - * not merely that space hygiene is unavailable. - * @throws {SqliteDatabaseFileError} when that first statement's answer is a - * verdict on the file itself ({@link SQLITE_FILE_REFUSALS}): it cannot be - * opened, it is not a SQLite database, or its header or schema is damaged. - */ - private async applyAutoVacuumIncremental(): Promise { - try { - const current = SqlDriver.autoVacuumOf(await this.knex.raw('PRAGMA auto_vacuum')); - if (current !== SqlDriver.SQLITE_AUTO_VACUUM_INCREMENTAL) { - await this.knex.raw('PRAGMA auto_vacuum = INCREMENTAL'); - } - return true; - } catch (e) { - // A native better-sqlite3 load failure surfaces HERE first — this PRAGMA - // is the first query that forces the lazy `.node` addon to load. Two - // real-world variants, both handled by `resolveSqliteDriver`'s probe, - // which catches the failure and steps down to wasm SQLite with a clean - // one-line notice (#2229): - // • ABI mismatch — `ERR_DLOPEN_FAILED` / "…NODE_MODULE_VERSION 127…" - // (a stale prebuilt binary after a Node upgrade) - // • not built — "Could not locate the bindings file" (native addon - // never compiled, e.g. a fresh clone / blocked build) - // Dumping the full multi-line stack for either looks like a fatal crash - // to the reader (it isn't), so log a concise, actionable one-liner and - // let the step-down message that follows explain the outcome. Any OTHER - // PRAGMA failure keeps the full warning (with stack) as before. - const code = (e as { code?: string } | null | undefined)?.code; - // The file itself is refused, and every later statement would answer the - // same, so the connect fails here, naming the file. Before, each of these - // logged and resolved, and the datasource counted as connected while it - // could not answer one query: - // • SQLITE_CANTOPEN (#23696) — a directory that exists but refuses the - // file, a path SQLite cannot open; - // • SQLITE_NOTADB (#24960) — the file exists and opens, but its bytes - // are not a SQLite database (plain text, a foreign format, a file - // encrypted by another engine). On a development boot the step-down - // used to answer it with wasm SQLite, whose open moved the - // operator's file aside and wrote an empty database in its place. - // The refusal is issued before anything is written: the file is left - // exactly as it was found; - // • SQLITE_CORRUPT (#25019) — the file is a SQLite database SQLite - // cannot read: its header or its schema is damaged (a file truncated - // inside its 100-byte header, or short of the pages its schema - // names). At the FIRST statement this is a verdict on the whole - // file, measured: `SELECT 1` answers the same code. A database - // damaged deeper inside answers this read and connects; its damage - // surfaces on the read that reaches it, as before. A development - // boot used to take the same step-down to wasm SQLite as for - // SQLITE_NOTADB, and the same rename; it is refused the same way, - // before anything is written. - const refusal = code === undefined ? undefined : SqlDriver.SQLITE_FILE_REFUSALS.get(code); - if (refusal !== undefined) { - const declared = (this.config as any).connection; - const filename = this.sqliteFilename() ?? String(typeof declared === 'string' ? declared : declared?.filename); - throw new SqliteDatabaseFileError(filename, refusal, e); - } - const msg = e instanceof Error ? e.message : String(e); - const isNativeLoadFailure = - code === 'ERR_DLOPEN_FAILED' || - code === 'MODULE_NOT_FOUND' || - code === 'ERR_MODULE_NOT_FOUND' || - /NODE_MODULE_VERSION|could not locate the bindings|was compiled against a different/i.test(msg); - if (isNativeLoadFailure) { - this.logger.warn( - 'native better-sqlite3 unavailable (ABI mismatch or not built) — will step down to wasm SQLite; run `pnpm rebuild better-sqlite3` for native speed', - ); - } else { - this.logger.warn('Failed to set PRAGMA auto_vacuum=INCREMENTAL', e); - } - return false; - } + private applyAutoVacuumIncremental(): Promise { + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts`, its docblock with it (see `LifecycleHost`). + return LifecycleFacet.applyAutoVacuumIncremental.call(lifecycleHost.of(this)); } /** @@ -2083,21 +1650,9 @@ export class SqlDriver implements IDataDriver { /** What `PRAGMA auto_vacuum` answers for INCREMENTAL (0 is NONE, 1 is FULL). */ private static readonly SQLITE_AUTO_VACUUM_INCREMENTAL = 2; - /** - * The `auto_vacuum` mode a PRAGMA reply carries, as a number. - * - * Both SQLite dialects answer with rows (`[{ auto_vacuum: 2 }]`) — a reply - * shaped any other way yields `null`, which {@link applyAutoVacuumIncremental} - * treats as "not INCREMENTAL" and answers with the setter. - */ private static autoVacuumOf(result: unknown): number | null { - const rows: unknown = Array.isArray(result) ? result : (result as { rows?: unknown })?.rows; - const row = Array.isArray(rows) ? rows[0] : undefined; - const value = - row && typeof row === 'object' ? (row as { auto_vacuum?: unknown }).auto_vacuum : row; - if (typeof value === 'number') return value; - if (typeof value === 'bigint') return Number(value); - return null; + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts`, its docblock with it. + return LifecycleFacet.autoVacuumOf(result); } /** @@ -2116,14 +1671,8 @@ export class SqlDriver implements IDataDriver { * must not quietly leave a database serialized. */ protected resolveSqliteJournalMode(): SqliteJournalMode { - const fromEnv = (process.env[SqlDriver.SQLITE_JOURNAL_MODE_ENV] ?? '').trim().toLowerCase(); - const requested = this.declaredJournalMode ?? (fromEnv || undefined); - if (requested === undefined) return 'wal'; - if (requested === 'wal' || requested === 'delete') return requested; - this.logger.warn( - `Ignoring SQLite journal mode '${requested}' (expected 'wal' or 'delete') — using 'wal'`, - ); - return 'wal'; + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts` (see `LifecycleHost`). + return LifecycleFacet.resolveSqliteJournalMode.call(lifecycleHost.of(this)); } /** @@ -2183,145 +1732,34 @@ export class SqlDriver implements IDataDriver { * journal is slower under concurrency, not broken, and refusing to start over * a pragma would be worse than the problem this solves. */ - protected async applySqliteJournalMode(): Promise { - if (!this.supportsWalJournal) return; - - // No file means nobody to share it with: `:memory:` (and every other - // `:`-prefixed pseudo-filename) is private to this process. - const filename = this.sqliteFilename(); - if (!filename) return; - - const target = this.resolveSqliteJournalMode(); - - let current: string | null; - try { - current = SqlDriver.journalModeOf(await this.knex.raw('PRAGMA journal_mode')); - } catch (e) { - this.logger.warn(`Failed to read SQLite journal_mode on ${filename}`, e); - return; - } - - // `memory` / `off` is a deliberate no-journal transport, not a rollback - // journal worth migrating — leave whatever the host chose in place. - if (current === null || current === 'memory' || current === 'off') return; - - if (current === target) { - // Nothing to change — but when the mode is already WAL, still prove it is - // usable. A database an earlier boot left in WAL on a filesystem that - // cannot serve it (failure mode 2) would otherwise fail every query with - // nothing pointing at the cause, and the revert that would fix it only - // ever gets one attempt. - if (target === 'wal') await this.verifyWalReadable(filename); - return; - } - - let applied: string | null; - try { - applied = SqlDriver.journalModeOf( - await this.knex.raw(`PRAGMA journal_mode = ${target.toUpperCase()}`), - ); - } catch (e) { - // SQLITE_BUSY is the benign case: a mode change needs the file to itself, - // and another process has it open. Whoever wins the race sets the mode - // and everyone else reads it back on their next boot. - this.logger.warn( - `Failed to set SQLite journal_mode=${target} on ${filename} (it stays '${current}')`, - e, - ); - return; - } - - if (applied !== target) { - this.logger.warn( - `SQLite kept journal_mode='${applied ?? 'unknown'}' on ${filename} instead of '${target}' — ` + - (target === 'wal' - ? `WAL needs a local filesystem (it does not work on NFS/SMB), so this database stays serialized between processes. ` + - `Set ${SqlDriver.SQLITE_JOURNAL_MODE_ENV}=delete to stop asking.` - : `close every other connection and retry, or run \`sqlite3 ${filename} "PRAGMA journal_mode=delete"\` with nothing attached.`), - ); - return; - } - - if (target === 'wal') await this.verifyWalReadable(filename); + protected applySqliteJournalMode(): Promise { + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts` (see `LifecycleHost`). + return LifecycleFacet.applySqliteJournalMode.call(lifecycleHost.of(this)); } - /** - * Prove a WAL database can actually be read through, and undo the mode if - * not — see failure mode 2 in {@link applySqliteJournalMode}. Runs on every - * connect to a WAL database, not only right after a switch, so a database - * some earlier boot stranded still gets recovered. - * - * Any read on a WAL database opens the shared-memory index, which is the part - * a network filesystem cannot provide; `sqlite_master` is the cheapest such - * read and needs no schema to exist. - */ - private async verifyWalReadable(filename: string): Promise { - try { - await this.knex.raw('SELECT count(*) FROM sqlite_master'); - return; - } catch (e) { - // Busy is not unusable. Another connection in EXCLUSIVE locking mode — the - // `os migrate` occupancy probe takes exactly that, briefly — makes this - // read fail for a reason that passes on its own, and changing the journal - // mode underneath it would be both wrong and impossible. - if (SqlDriver.isSqliteBusyError(e)) return; - - const detail = e instanceof Error ? e.message : String(e); - let reverted: string | null = null; - try { - reverted = SqlDriver.journalModeOf(await this.knex.raw('PRAGMA journal_mode = DELETE')); - } catch { - // Leaving `reverted` null — reported as "did not take" below, which is - // the honest outcome either way. - } - this.logger.warn( - `SQLite accepted WAL on ${filename} but cannot read through it (${detail}) — ` + - (reverted === 'delete' - ? `reverted to journal_mode=delete. Set ${SqlDriver.SQLITE_JOURNAL_MODE_ENV}=delete to skip this on every boot.` - : `and the revert did not take (journal_mode='${reverted ?? 'unknown'}'). Run ` + - `\`sqlite3 ${filename} "PRAGMA journal_mode=delete"\` with nothing attached, then set ` + - `${SqlDriver.SQLITE_JOURNAL_MODE_ENV}=delete.`), - ); - } + private verifyWalReadable(filename: string): Promise { + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts`, its docblock with it (see `LifecycleHost`). + return LifecycleFacet.verifyWalReadable.call(lifecycleHost.of(this), filename); } - /** - * Whether an error is SQLite reporting contention rather than a broken - * database. Mirrors `isBusyError` in the CLI's occupancy probe, which asks the - * same question of the same engine from the other side of the package graph. - */ private static isSqliteBusyError(e: unknown): boolean { - const code = (e as { code?: string } | null | undefined)?.code ?? ''; - const message = e instanceof Error ? e.message : String(e ?? ''); - return code.startsWith('SQLITE_BUSY') || /database is locked|SQLITE_BUSY/i.test(message); + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts`, its docblock with it. + return LifecycleFacet.isSqliteBusyError(e); } - /** - * The `journal_mode` a PRAGMA reply carries, lower-cased. - * - * Both SQLite dialects answer a PRAGMA with rows (`[{ journal_mode: 'wal' }]`) - * — a reply shaped any other way yields `null`, which every caller treats as - * "leave the database alone" rather than guessing. - */ private static journalModeOf(result: unknown): string | null { - const rows: unknown = Array.isArray(result) ? result : (result as { rows?: unknown })?.rows; - const row = Array.isArray(rows) ? rows[0] : undefined; - const value = - row && typeof row === 'object' ? (row as { journal_mode?: unknown }).journal_mode : row; - return typeof value === 'string' ? value.toLowerCase() : null; + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts`, its docblock with it. + return LifecycleFacet.journalModeOf(result); } - async checkHealth(): Promise { - try { - await this.knex.raw('SELECT 1'); - return true; - } catch { - return false; - } + checkHealth(): Promise { + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts` (see `LifecycleHost`). + return LifecycleFacet.checkHealth.call(lifecycleHost.of(this)); } - async disconnect(): Promise { - await this.knex.destroy(); + disconnect(): Promise { + // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts` (see `LifecycleHost`). + return LifecycleFacet.disconnect.call(lifecycleHost.of(this)); } // =================================== @@ -2335,167 +1773,19 @@ export class SqlDriver implements IDataDriver { // appeared in neither its repaired set nor its excluded set — it was never // seen. Pinned at the type level, both halves, in // `sql-driver-doors-declared-types.test.ts`. - async find(object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { - return this.findRows(object, query, options); - } - - /** - * The body of {@link find}, shared with {@link findOne}. - * - * `singleRowLookup` marks the caller as `findOne` rather than a page, and the - * only thing it changes is that no ordering is imposed on an unsorted read - * (objectstack#4363). `findOne` reaches here as `limit: 1`, which is - * indistinguishable from "page one of a walk with page size 1" — and the two - * want opposite things: - * - * - The paged read needs a total order, or its pages are not a partition. - * - `findOne` needs the plan its predicate earned. `ORDER BY id LIMIT 1` is - * the shape that makes a planner abandon the predicate's own index and walk - * the primary key applying a filter instead, because at `LIMIT 1` that - * looks like it will stop early. Measured on Postgres 16 over 2M rows, - * `WHERE owner_id = ? LIMIT 1` went 0.08 ms → 7.8 ms (~100×) and swapped - * `ticket_owner_idx` for `ticket_pkey`; at `LIMIT 5` and up the planner - * keeps the right index and pays only a top-N sort (~2 ms). Nothing is - * bought for that: `findOne` promises *a* matching record, never a position - * in a sequence, so there is no partition to preserve. - * - * `MongoDBDriver.findOne` carries the same flag into its own `buildSortSpec`, - * so both drivers now read a `findOne` the same way: honour the caller's - * `orderBy`, impose nothing when there is none. (Mongo used to translate - * `where` and drop `orderBy` outright — an earlier version of this comment - * cited that as agreement, which it was not; objectstack#4419.) The - * obligation the contract states is on `find`, and this keeps it there. - */ - private async findRows( + find(object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { + // [#29520 codemod] The body moved to `sql-driver-crud.ts` (see `CrudHost`). + return CrudFacet.find.call(crudHost.of(this), object, query, options); + } + + private findRows( object: string, query: DriverQuery, options?: DriverOptions, singleRowLookup = false, ): Promise { - // The whole ORDER BY, decided once: the caller's keys plus whatever this - // driver has to add to make a paged read deterministic. Derived out here - // rather than inside `buildBase` so the recovery ladder below can ask - // whether the statement HAS a sort to drop, instead of asking whether the - // caller supplied one — since #4363 those are different questions. - const orderKeys = this.orderKeysFor(object, query, { singleRowLookup }); - - // Build everything EXCEPT the SELECT list, so the unknown-column retry - // below can rebuild without re-deriving where/order/pagination. - const buildBase = (opts?: { withOrderBy?: boolean }) => { - const withOrderBy = opts?.withOrderBy !== false; - const b = this.getBuilder(object, options); - this.applyTenantScope(b, object, options); - - // WHERE - if (query.where) { - this.applyFilters(b, query.where, options); - } - - // ORDER BY - if (withOrderBy) { - for (const key of orderKeys) { - b.orderBy(this.remoteColumn(object, key.field, this.mapSortField(key.field)), key.direction); - } - } - - // PAGINATION - if (query.offset !== undefined) b.offset(query.offset); - if (query.limit !== undefined) b.limit(query.limit); - - return b; - }; - - const builder = buildBase(); - - // SELECT - if (Array.isArray(query.fields) && query.fields.length > 0) { - builder.select(query.fields.map((f) => this.mapSortField(f))); - } else { - builder.select('*'); - } - - let results: any[]; - try { - results = await builder; - } catch (error: any) { - if (isUnresolvableColumnError(error)) { - // A `$select` projection naming a column the table lacks (e.g. a - // generic list view auto-requesting `status`/`due_date`/`image` on an - // object without them) makes the WHOLE query fail. Swallowing that - // into an empty result — the old behavior — reads to the UI as "no - // records exist" even though rows are there: a silent data-loss - // footgun. When the failure came from the projection, retry once - // selecting all columns so the real rows still come back; the unknown - // field is simply absent from each row (it never existed). The - // engine's unknown-field filter is the first line of defense, but it - // only fires when the object's schema is populated in the registry — - // this driver backstop holds even when it isn't (notably the cloud - // multi-tenant runtime, where the projection otherwise zeroes the list). - // - // An unknown ORDER BY column is the same footgun one clause over - // (objectstack#3821): a client that sorts by a field the table lacks - // used to get an empty page with a non-zero `total` — "the rows are - // there but none are shown". Rows matter more than their order, so - // drop the sort and return them unordered rather than nothing. Ladder: - // projection first (it is the likelier culprit and the cheaper thing - // to lose), then the sort, then give up. - // - // [#8790, maintainer ruling 2026-08-15] Where the ladder STOPS is the - // other half of the same argument. Every rung is built from - // `buildBase()`, which always re-applies `query.where` — so the ladder - // can drop a projection and can drop a sort, but it can never drop the - // clause that failed when the unresolvable column is in the WHERE. - // Both rungs then raise the same error and the method used to fall to - // `return []`. That terminal is now a refusal: rows matter more than - // their order, but nothing matters more than the predicate, because - // answering without it returns records the caller excluded. See - // {@link unresolvableFilterColumnError}. The two recoveries above are - // deliberately untouched — the ruling narrows the terminal only. - const retries: Array<() => any> = []; - if (query.fields) retries.push(() => buildBase().select('*')); - if (orderKeys.length > 0) { - retries.push(() => buildBase({ withOrderBy: false }).select('*')); - } - results = []; - let recovered = false; - // The error to NAME the column from. The last rung is the one that has - // dropped everything droppable, so a column still unresolved there is - // the caller's WHERE — naming it from the ORIGINAL error would name the - // projection column on a query whose projection the ladder just fixed. - let lastError: unknown = error; - for (const retry of retries) { - try { - results = await retry(); - recovered = true; - break; - } catch (retryError) { - // Try the next, broader fallback. - lastError = retryError; - } - } - if (!recovered) throw this.unresolvableFilterColumnRefusal(object, lastError, query.where); - } else { - // [#8931] The terminal catch-all — see - // {@link SqlDriver.backendStatementFault}. This is the `find` half of - // the exit `count` spells below; both halves must answer one condition - // one way, which is the invariant #8790 established on this path and - // this ruling extends to every condition the ladder does not recover. - throw this.backendStatementFault(object, error); - } - } - - if (!Array.isArray(results)) { - return []; - } - - // formatOutput is dialect-agnostic for `Field.date` (ADR-0053 Phase 1); - // its json/boolean deserialisation stays SQLite-gated internally. Run it - // for every dialect so reads match `findOne` and date columns come back - // as `YYYY-MM-DD`. - for (const row of results) { - this.formatOutput(object, row); - } - return results; + // [#29520 codemod] The body moved to `sql-driver-crud.ts`, its docblock with it (see `CrudHost`). + return CrudFacet.findRows.call(crudHost.of(this), object, query, options, singleRowLookup); } /** @@ -2521,10 +1811,9 @@ export class SqlDriver implements IDataDriver { * complaint; it is the contract's type now, pinned by * `sql-driver-doors-declared-types.test.ts`. */ - async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise | null> { - if (!query || typeof query !== 'object') return null; - const results = await this.findRows(object, { ...query, limit: 1 }, options, true); - return results[0] || null; + findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise | null> { + // [#29520 codemod] The body moved to `sql-driver-crud.ts` (see `CrudHost`). + return CrudFacet.findOne.call(crudHost.of(this), object, query, options); } // `findStream` was removed with the contract method in 17.0.0 (#4484). This driver's @@ -2547,94 +1836,17 @@ export class SqlDriver implements IDataDriver { */ create(object: string, data: Record, options?: DriverOptions): Promise> { // [#29520 codemod] The body moved to `sql-driver-crud.ts` (see `CrudHost`). - const readInsertReturnsStoredRows = () => this.insertReturnsStoredRows; - const host: ThisParameterType = { - applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), - auditMissingTenant: (...a) => this.auditMissingTenant(...a), - collidingAutoNumberReservations: (...a) => this.collidingAutoNumberReservations(...a), - decorateHashShadowDuplicate: (...a) => this.decorateHashShadowDuplicate(...a), - fillAutoNumberFields: (...a) => this.fillAutoNumberFields(...a), - formatInput: (...a) => this.formatInput(...a), - formatOutput: (...a) => this.formatOutput(...a), - getBuilder: this.getBuilder.bind(this), - injectTenantOnInsert: (...a) => this.injectTenantOnInsert(...a), - get insertReturnsStoredRows() { return readInsertReturnsStoredRows(); }, - readBackInsertedRows: (...a) => this.readBackInsertedRows(...a), - resyncSequenceToDataMax: (...a) => this.resyncSequenceToDataMax(...a), - rotationWriteTarget: (...a) => this.rotationWriteTarget(...a), - stampInsertTimestamps: (...a) => this.stampInsertTimestamps(...a), - }; - return create.call(host, object, data, options); - } - - /** - * [#21227] Read back the rows an INSERT on this call just wrote, by the ids - * it wrote: what `create` and `bulkCreate` answer on a dialect whose INSERT - * answers no rows ({@link insertReturnsStoredRows}). - * - * One row per written row, in the written order (`IN (…)` promises no - * order), each through `formatOutput()`, the same presentation - * `returning('*')` gets on the other dialects and `update`'s own read-back - * gets on every dialect. - * - * # The read key: the written id, which this driver always holds - * - * Every row reaches the INSERT carrying the id this driver gave it: the - * caller's `id`, else its `_id`, else a nanoid minted in `create` / - * `bulkCreate` before the statement is built. The id is never asked of the - * database, so no insert id is read: the managed `id` column is a - * `varchar(255)` PRIMARY KEY with no AUTO_INCREMENT, and the insert id - * MySQL reports for it is `0`. The column is resolved through - * {@link remoteColumn}, so an external object whose `columnMap` renames - * `id` is read by its physical column, as its INSERT was written. - * - * # The table: the write target - * - * The table the statement wrote, a rotation shard included, so the read - * looks where the row landed rather than at the base name. - * - * # Tenant scope: the tenants the rows were WRITTEN under - * - * Routed through {@link applyTenantScope} like every read door in this class - * (`check:tenant-chokepoint`), scoped as `upsert`'s identity probe - * (`assertMergeLandedOnSuppliedIdentity`) scopes its own read: to the tenant - * each row was written under. On an ordinary tenanted call that is the - * caller's org, which `injectTenantOnInsert` stamped. On an admin write that - * names a tenant in the row data (a documented authority: explicit values - * are never overwritten), a read scoped to the caller's ACTIVE org would miss - * a row that really landed. A batch may name several tenants, so the scope is - * their union through `tenantIds`, which `applyTenantScope` already compiles - * as `IN (…)`; the caller's own `tenantIds` membership set is replaced, not - * widened. A row written with no tenant is no tenant's, and no tenant scope - * reaches it (ADR-0131 D1), so the rows' union scopes the read only when - * every row carries one. Otherwise the call's own options decide, and a call - * with no tenant leaves a row unstamped only because it carries none: - * `injectTenantOnInsert` fills every absent column on a call that does. With - * no tenant field, or no tenant on the call, `applyTenantScope` leaves the - * read unscoped by its own contract. The ids are this call's own and `id` is - * the PRIMARY KEY, so the read cannot answer a row this call did not write, - * in any organization. - * - * # A row that is not there - * - * Refused with {@link insertedRowsNotReadBackError}, never answered with the - * payload: see that function for why. - */ + return CrudFacet.create.call(crudHost.of(this), object, data, options); + } + private readBackInsertedRows( object: string, writeTable: string, written: Record[], options?: DriverOptions, ): Promise[]> { - // [#29520 codemod] The body moved to `sql-driver-crud.ts` (see `CrudHost`). - const host: ThisParameterType = { - applyTenantScope: (...a) => this.applyTenantScope(...a), - formatOutput: (...a) => this.formatOutput(...a), - getBuilder: this.getBuilder.bind(this), - remoteColumn: (...a) => this.remoteColumn(...a), - resolveTenantField: (...a) => this.resolveTenantField(...a), - }; - return readBackInsertedRows.call(host, object, writeTable, written, options); + // [#29520 codemod] The body moved to `sql-driver-crud.ts`, its docblock with it (see `CrudHost`). + return CrudFacet.readBackInsertedRows.call(crudHost.of(this), object, writeTable, written, options); } /** @@ -2650,71 +1862,15 @@ export class SqlDriver implements IDataDriver { * generous non-indexed column. Fixed-prefix formats use the empty scope and * keep their single global counter (backward compatible). */ - protected async ensureSequencesTable(parentTrx?: Knex.Transaction): Promise { - if (this.sequencesTableReady) return; - if (this.sequencesTableEnsurePromise) { - await this.sequencesTableEnsurePromise; - return; - } - // Which connection runs the DDL below. Normally a fresh pooled connection - // (`this.knex`), because `initObjects` pre-creates the table outside any data - // transaction. This lazy path is the fallback (e.g. an external object, or a - // consumer that writes without `initObjects`). If we are already inside the - // caller's transaction AND the pool can only ever hand out one connection - // (SQLite, pool max=1), that connection is busy with the open transaction — - // a bare `this.knex` here would block forever acquiring a second one and then - // fail with a Knex acquire-timeout (the reported batch/autonumber deadlock). - // Run the DDL on the caller's own transaction instead; SQLite permits DDL - // inside a transaction. We deliberately do NOT route DDL through `parentTrx` - // on MySQL, where DDL implicitly commits the caller's transaction; there the - // pool has room (max=5) for a fresh connection to create the table safely. - // The 5 is `buildSqlPool`'s — service-datasource's - // `default-datasource-driver-factory.ts` gives every datasource it composes - // `{min:0,max:5}` unless one declares its own `pool` — not knex's roomier - // `poolDefaults()` of `{min:2,max:10}`, which the factory never reaches. - const runner: Knex | Knex.Transaction = parentTrx && this.isSqlite ? parentTrx : this.knex; - // If we are about to run DDL on a fresh pooled connection while a SQLite - // transaction holds the only one, fail fast with a clear message instead of - // dead-locking. This catches the "caller opened a transaction but did not - // thread it through as parentTrx" regression at the call site (dev/test only). - if (runner === this.knex) this.assertBareKnexSafe('ensureSequencesTable'); - this.sequencesTableEnsurePromise = (async () => { - const exists = await runner.schema.hasTable(SEQUENCES_TABLE); - if (!exists) { - try { - await this.createSequencesTable(SEQUENCES_TABLE, runner); - this.sequencesHasKeyHash = true; - } catch (err: any) { - // Race or cross-process create — re-check existence; ignore - // "already exists" errors from any dialect. - const stillMissing = !(await runner.schema.hasTable(SEQUENCES_TABLE)); - if (stillMissing) throw err; - // A racing creator may have used an older schema. Migrate in place. - await this.ensureSequencesKeyHashShape(runner); - } - } else { - // Pre-existing table may predate the `key_hash`/`scope` shape. Migrate. - await this.ensureSequencesKeyHashShape(runner); - } - // Cache "ready" only when the DDL ran on a durable connection. If it rode - // the caller's transaction (the SQLite in-tx fallback above), the table is - // commit-conditional — a rollback would drop it — so leave the flag unset - // and re-verify (a cheap `hasTable`) on the next write rather than trusting - // a stale process-level flag. `initObjects` sets it durably up front, so - // the hot path is unaffected. - if (runner === this.knex) this.sequencesTableReady = true; - })(); - try { - await this.sequencesTableEnsurePromise; - } finally { - this.sequencesTableEnsurePromise = null; - } + protected ensureSequencesTable(parentTrx?: Knex.Transaction): Promise { + // [#29520 codemod] The body moved to `sql-driver-crud.ts` (see `CrudHost`). + return CrudFacet.ensureSequencesTable.call(crudHost.of(this), parentTrx); } /** SHA-256 of the composite counter key — the table's single-column PK. */ protected sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string { // [#29520 codemod] The body moved to `sql-driver-crud.ts`. - return sequenceKeyHash(object, tenantId, field, scope); + return CrudFacet.sequenceKeyHash(object, tenantId, field, scope); } // ── The sequence semantics a second face shares, stated once ────────────── @@ -2758,10 +1914,8 @@ export class SqlDriver implements IDataDriver { table: string, field: string, ): string { - if (!tenantField) return GLOBAL_TENANT; - if (tenantId) return String(tenantId); - if (this.organizationObjectRegistered) throw new OrganizationlessCounterRefusal(table, field, tenantField); - return GLOBAL_TENANT; + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.resolveSequenceTenantId.call(sequenceSemanticsHost.of(this), tenantField, tenantId, table, field); } /** @@ -2773,11 +1927,7 @@ export class SqlDriver implements IDataDriver { */ protected defineSequencesTable(t: Knex.CreateTableBuilder): void { // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). - const readKnex = () => this.knex; - const host: ThisParameterType = { - get knex() { return readKnex(); }, - }; - return defineSequencesTable.call(host, t); + return SequenceSemanticsFacet.defineSequencesTable.call(sequenceSemanticsHost.of(this), t); } /** @@ -2785,11 +1935,12 @@ export class SqlDriver implements IDataDriver { * connection the DDL runs on (a fresh pooled connection by default, or the * caller's transaction on SQLite — see {@link ensureSequencesTable}). */ - protected async createSequencesTable( + protected createSequencesTable( table: string, runner: Knex | Knex.Transaction = this.knex, ): Promise { - await runner.schema.createTable(table, (t) => this.defineSequencesTable(t)); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.createSequencesTable.call(sequenceSemanticsHost.of(this), table, runner); } /** @@ -2804,47 +1955,11 @@ export class SqlDriver implements IDataDriver { * sequences keep working via the legacy key and per-scope writes error * actionably (see getNextSequenceValue), rather than corrupting data. */ - protected async ensureSequencesKeyHashShape( + protected ensureSequencesKeyHashShape( runner: Knex | Knex.Transaction = this.knex, ): Promise { - if (await runner.schema.hasColumn(SEQUENCES_TABLE, 'key_hash')) { - this.sequencesHasKeyHash = true; - return; - } - const hasScope = await runner.schema.hasColumn(SEQUENCES_TABLE, 'scope'); - const TMP = `${SEQUENCES_TABLE}__rebuild`; - try { - const rows: any[] = await runner(SEQUENCES_TABLE).select('*'); - await runner.schema.dropTableIfExists(TMP); - await this.createSequencesTable(TMP, runner); - const migrated = rows.map((r) => { - const scope = hasScope && r.scope != null ? String(r.scope) : ''; - return { - key_hash: this.sequenceKeyHash(String(r.object), String(r.tenant_id), String(r.field), scope), - object: r.object, - tenant_id: r.tenant_id, - field: r.field, - scope, - last_value: r.last_value ?? 0, - updated_at: r.updated_at ?? this.knex.fn.now(), - }; - }); - if (migrated.length > 0) await runner(TMP).insert(migrated); - await runner.schema.dropTable(SEQUENCES_TABLE); - await runner.schema.renameTable(TMP, SEQUENCES_TABLE); - this.sequencesHasKeyHash = true; - } catch (err) { - // Leave the original table intact; fall back to legacy keying for - // fixed-prefix sequences and refuse per-scope writes until migrated. - this.sequencesHasKeyHash = false; - await runner.schema.dropTableIfExists(TMP).catch(() => {}); - this.logger.warn( - `[autonumber] Failed to migrate ${SEQUENCES_TABLE} to the key_hash shape. ` + - `Fixed-prefix autonumbers keep working; date/{field}/per-parent formats will ` + - `error until the table is migrated.`, - { error: String(err) }, - ); - } + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.ensureSequencesKeyHashShape.call(sequenceSemanticsHost.of(this), runner); } /** @@ -2933,7 +2048,7 @@ export class SqlDriver implements IDataDriver { * has one spelling everywhere. Turso's remote face sends its own statement * to a SQLite engine only, and declares the same backslash there. */ - protected async scanMaxNumericTail( + protected scanMaxNumericTail( queryRunner: Knex | Knex.Transaction, tableName: string, field: string, @@ -2942,15 +2057,8 @@ export class SqlDriver implements IDataDriver { tenantId: string | null, suffix = '', ): Promise { - let builder = queryRunner(tableName) - .select(field) - .whereRaw('?? like ? escape ?', [field, `${this.escapeLikePrefix(prefix)}%`, LIKE_ESCAPE_CHARACTER]) - .whereNotNull(field); - if (tenantField && tenantId !== null) { - builder = builder.where(tenantField, tenantId); - } - const rows = await builder; - return this.maxAutonumberCounter((rows as any[]).map((r) => (r as any)[field]), prefix, suffix); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.scanMaxNumericTail.call(sequenceSemanticsHost.of(this), queryRunner, tableName, field, prefix, tenantField, tenantId, suffix); } /** @@ -2962,7 +2070,8 @@ export class SqlDriver implements IDataDriver { * re-checks the prefix per row. */ protected escapeLikePrefix(prefix: string): string { - return prefix.replace(/([\\%_])/g, '\\$1'); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts`. + return SequenceSemanticsFacet.escapeLikePrefix(prefix); } /** @@ -3025,16 +2134,12 @@ export class SqlDriver implements IDataDriver { * silently affects zero rows cannot tell "another writer raced me" apart from * "the row was rejected for some other reason". */ - protected async attemptWithoutPoisoning( + protected attemptWithoutPoisoning( trx: Knex.Transaction, attempt: (scoped: Knex.Transaction) => Promise | Knex.QueryBuilder, ): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> { - try { - const value = await trx.transaction(async (scoped) => attempt(scoped)); - return { ok: true, value: value as T }; - } catch (error) { - return { ok: false, error }; - } + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts`. + return (SequenceSemanticsFacet.attemptWithoutPoisoning)(trx, attempt); } /** @@ -3228,7 +2333,7 @@ export class SqlDriver implements IDataDriver { reservation: AutoNumberReservation, ): Promise { // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts`. - return autoNumberValueExists(queryRunner, reservation); + return SequenceSemanticsFacet.autoNumberValueExists(queryRunner, reservation); } /** @@ -3312,11 +2417,7 @@ export class SqlDriver implements IDataDriver { */ protected autoNumberCounterKey(reservation: AutoNumberReservation): string { // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). - const host: ThisParameterType = { - resolveSequenceTenantId: (...a) => this.resolveSequenceTenantId(...a), - sequenceKeyHash: (...a) => this.sequenceKeyHash(...a), - }; - return autoNumberCounterKey.call(host, reservation); + return SequenceSemanticsFacet.autoNumberCounterKey.call(sequenceSemanticsHost.of(this), reservation); } /** @@ -3339,11 +2440,8 @@ export class SqlDriver implements IDataDriver { * default already stores a zone-aware TIMESTAMP. */ protected stampInsertTimestamps(object: string, formatted: Record): void { - if (!this.isSqlite || !this.tablesWithTimestamps.has(object)) return; - const iso = new Date().toISOString(); - for (const col of AUDIT_TIMESTAMP_COLUMNS) { - if (formatted[col] === undefined || formatted[col] === null) formatted[col] = iso; - } + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.stampInsertTimestamps.call(sequenceSemanticsHost.of(this), object, formatted); } /** @@ -3359,7 +2457,8 @@ export class SqlDriver implements IDataDriver { * today into a hard failure. See {@link stampUpsertUpdatedAt}. */ protected observedUpdatedAtColumn(object: string): boolean { - return this.tablesWithTimestamps.has(object) || this.updatedAtColumnState.get(object) === 'present'; + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.observedUpdatedAtColumn.call(sequenceSemanticsHost.of(this), object); } /** @@ -3394,9 +2493,8 @@ export class SqlDriver implements IDataDriver { * why the commit 479fba50d presumption stops at the UPDATE door. */ protected stampUpsertUpdatedAt(object: string, formatted: Record): void { - if (formatted.updated_at !== undefined && formatted.updated_at !== null) return; - if (!this.observedUpdatedAtColumn(object)) return; - formatted.updated_at = this.updatedAtStamp(); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.stampUpsertUpdatedAt.call(sequenceSemanticsHost.of(this), object, formatted); } /** @@ -3408,7 +2506,8 @@ export class SqlDriver implements IDataDriver { * A normal update leaves the flag unset, so `updated_at` always advances. */ protected keepSuppliedUpdatedAt(formatted: Record, options?: DriverOptions): boolean { - return options?.preserveAudit === true && formatted.updated_at != null; + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts`. + return SequenceSemanticsFacet.keepSuppliedUpdatedAt(formatted, options); } /** @@ -3459,8 +2558,8 @@ export class SqlDriver implements IDataDriver { * is no longer a second place for the two doors to drift apart. */ protected updatedAtStamp(): string | Knex.Raw { - if (this.isSqlite) return new Date().toISOString(); - return this.isMysql ? this.knex.fn.now(MYSQL_DATETIME_PRECISION) : this.knex.fn.now(); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.updatedAtStamp.call(sequenceSemanticsHost.of(this)); } /** @@ -3473,9 +2572,8 @@ export class SqlDriver implements IDataDriver { * of this object" both stay `false`. */ protected stampsUpdatedAt(object: string): boolean { - if (this.tablesWithTimestamps.has(object)) return true; - const state = this.updatedAtColumnState.get(object); - return state === 'presumed' || state === 'present'; + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.stampsUpdatedAt.call(sequenceSemanticsHost.of(this), object); } /** @@ -3487,7 +2585,8 @@ export class SqlDriver implements IDataDriver { * `false` forever after, which is what keeps the steady state free. */ protected updatedAtStampIsPresumed(object: string): boolean { - return !this.tablesWithTimestamps.has(object) && this.updatedAtColumnState.get(object) === 'presumed'; + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.updatedAtStampIsPresumed.call(sequenceSemanticsHost.of(this), object); } /** @@ -3609,22 +2708,7 @@ export class SqlDriver implements IDataDriver { */ update(object: string, id: string | number, data: Record, options?: DriverOptions): Promise | null> { // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). - const host: ThisParameterType = { - applyTenantScope: (...a) => this.applyTenantScope(...a), - applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), - auditMissingTenant: (...a) => this.auditMissingTenant(...a), - formatInput: (...a) => this.formatInput(...a), - formatOutput: (...a) => this.formatOutput(...a), - getBuilder: this.getBuilder.bind(this), - keepSuppliedUpdatedAt: (...a) => this.keepSuppliedUpdatedAt(...a), - rotatedUpdateById: (...a) => this.rotatedUpdateById(...a), - rotationShardsOf: (...a) => this.rotationShardsOf(...a), - stampsUpdatedAt: (...a) => this.stampsUpdatedAt(...a), - updateWithPresumedTimestamp: (...a) => this.updateWithPresumedTimestamp(...a), - updatedAtStamp: (...a) => this.updatedAtStamp(...a), - updatedAtStampIsPresumed: (...a) => this.updatedAtStampIsPresumed(...a), - }; - return update.call(host, object, id, data, options); + return SequenceSemanticsFacet.update.call(sequenceSemanticsHost.of(this), object, id, data, options); } /** @@ -3717,15 +2801,8 @@ export class SqlDriver implements IDataDriver { * holds on both faces from one place. */ protected insertOnlyUpsertColumns(object: string): Set { - // Same config resolution as `fillAutoNumberFields`: object name first, - // then the storage-mapped table name. - const tableName = this.physicalTableByObject[object] ?? StorageNameMapping.resolveTableName({ name: object } as any); - const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName] || []; - const columns = new Set(['created_at', this.remoteColumn(object, 'id', 'id')]); - for (const cfg of cfgs) columns.add(this.remoteColumn(object, cfg.name, cfg.name)); - const tenantField = this.resolveTenantField(object); - if (tenantField) columns.add(this.remoteColumn(object, tenantField, tenantField)); - return columns; + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.insertOnlyUpsertColumns.call(sequenceSemanticsHost.of(this), object); } /** @@ -3765,22 +2842,9 @@ export class SqlDriver implements IDataDriver { return upsertTenantGuard.call(host, object, row, options); } - /** - * [#21185] The merge leg's organization predicate on the dialects whose - * `ON CONFLICT … DO UPDATE` takes a `WHERE`: the stored row's tenant column - * is NOT DISTINCT from the one the statement would have inserted - * (`excluded`). SQLite spells NULL-safe equality `IS`, PostgreSQL - * `IS NOT DISTINCT FROM` — the two dialects this is reached on (MySQL fences - * after the statement instead; see {@link upsert}). - * - * The target table is the write target's bare PHYSICAL name — the rotation - * shard when one is current, the federated remote table otherwise — because - * that is how both dialects name the INSERT target inside this clause. - */ private mergeLandsInWrittenOrganization(writeTable: string, column: string): Knex.Raw { - const table = this.physicalTableByObject[writeTable] ?? writeTable; - const notDistinct = this.isPostgres ? 'IS NOT DISTINCT FROM' : 'IS'; - return this.knex.raw(`?? ${notDistinct} ??`, [`${table}.${column}`, `excluded.${column}`]); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts`, its docblock with it (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.mergeLandsInWrittenOrganization.call(sequenceSemanticsHost.of(this), writeTable, column); } /** @@ -3974,90 +3038,6 @@ export class SqlDriver implements IDataDriver { return assertConflictTargetBackedWithoutClause.call(host, object, writeTable, mergeKeys); } - /** - * [#8807] After a primary-key-targeted merge on a table that carries a rival - * UNIQUE key: did the statement land on the identity this call supplied? - * - * # Why the absence of the row is an exact verdict, not a heuristic - * - * The statement returned without error, so it either INSERTED (the row now - * carries this id) or MERGED into an existing row. `id` is insert-only on the - * merge path since #8622 — it is stripped from the merge set — so a merge - * never writes this id onto the row it lands on. Therefore: - * - * row with this id exists ⟺ the merge landed on the primary key - * row with this id absent ⟺ it landed on some other UNIQUE key - * - * That is a biconditional, not an inference from a symptom, which is what - * makes this check free of false refusals — the property the ruling's - * excluded blanket option could not have. - * - * # Why the PRIMARY KEY row wins a multi-key collision — measured, not assumed - * - * The biconditional needs one more fact than #8622 to be exact: when the - * incoming row collides on the primary key AND on a rival UNIQUE key at the - * same time, the row carrying our id exists — so "row present" must still mean - * "the statement landed on it", or a pre-existing row with our id would mask a - * merge that went elsewhere. MySQL updates only the FIRST matched index, and - * *which* one that is decides the question. Measured on live MySQL 8.0.46, - * `PRIMARY KEY (id)` + `UNIQUE (email)` + `UNIQUE (tax_id)`, rows - * `R1(id=R1, tax_id=T-1)` and `R2(id=R2, tax_id=T-2)`, inserting - * `(id=R1, tax_id=T-2)` — which collides with R1 on the primary key and with - * R2 on `uniq_tax`: - * - * ``` - * ROW_COUNT() = 2 (an update) - * R1 -> title='MERGED' ← the PRIMARY KEY row won - * R2 -> untouched - * ``` - * - * So the primary key is matched first and the masking case does not arise. - * - * # Tenant scope — applied, and scoped to the tenant the row was WRITTEN under - * - * The read routes through {@link applyTenantScope} like every other read door - * in this class (`check:tenant-chokepoint` asserts exactly that, on a bound - * builder). The subtlety is *which* tenant to scope to, and using the caller's - * active org would be wrong in one real case: - * - * - **Ordinary tenanted call** — nothing supplied a tenant on the row, so - * `injectTenantOnInsert` stamped `options.tenantId` on it. Scoping to the - * written tenant IS scoping to the caller's org: identical to the wall - * every sibling door applies. - * - **Admin writing to a specific tenant via raw row data** — a documented - * authority ({@link injectTenantOnInsert}: "explicit values are never - * overwritten"). The row lands under the tenant the caller named, so a read - * scoped to the caller's *active* org would miss a row that really was - * written and refuse a correct write. Scoping to the written tenant does - * not. - * - **No tenancy field, or no tenant context at all** — `applyTenantScope` - * returns the builder untouched when `tenantId` is empty, by its own - * contract. That is what keeps the lifecycle archiver working: it calls - * `cold.upsert(object, row, ['id'])` with NO options, so this read is - * unscoped there, exactly as before. - * - * The verdict itself is tenant-independent regardless: `id` is the PRIMARY - * KEY, so at most one row in the table can carry it. - * - * ⚠️ [#21185] The first two bullets no longer reach this method: a - * tenant-scoped call on a tenanted object is fenced to its organization, and - * on MySQL {@link assertMergeLandedInWrittenOrganization} runs in this - * check's place, reading under the written tenant and carrying #8807's - * verdict with it. What still arrives here is the third bullet — and there - * the written tenant must NOT become a scope: the tenant column is now - * insert-only, so a payload naming another organization than the stored - * row's is a merge that keeps the row's organization (measured on MariaDB - * 10.11 before this guard: the read scoped to the payload's tenant missed the - * merged row and answered this method's cross-row refusal for a merge that - * landed on the supplied id). So the written tenant is used only under a - * tenant context, and a call without one reads unscoped. - * - * # The write target, not the object - * - * A rotation-sharded write lands in the current shard, so that is where the - * row must be looked for. Reading the unsharded name would report every - * rotated write as a cross-row merge. - */ private assertMergeLandedOnSuppliedIdentity( object: string, writeTable: string, @@ -4066,71 +3046,10 @@ export class SqlDriver implements IDataDriver { writtenTenant: unknown, options?: DriverOptions, ): Promise { - // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). - const readPhysicalTableByObject = () => this.physicalTableByObject; - const host: ThisParameterType = { - applyTenantScope: (...a) => this.applyTenantScope(...a), - getBuilder: this.getBuilder.bind(this), - get physicalTableByObject() { return readPhysicalTableByObject(); }, - }; - return assertMergeLandedOnSuppliedIdentity.call(host, object, writeTable, id, rivals, writtenTenant, options); - } - - /** - * [#21185] MySQL: after a tenant-scoped upsert's statement, did it land on a - * row of the organization the row is WRITTEN under? Runs inside the same - * transaction as the statement, so its throw rolls the write back. - * - * # Why MySQL needs a check where the other dialects need none - * - * SQLite and PostgreSQL fence the merge leg inside the statement - * (`ON CONFLICT … DO UPDATE SET … WHERE` the stored tenant column equals the - * written one — see {@link upsert}), so a row of another organization is - * never written at all. MySQL's `ON DUPLICATE KEY UPDATE` takes no `WHERE` - * (knex refuses to compile one: `.onConflict().merge().where() is not - * supported for mysql`). This is #8807's mechanism, reused as ruled: the - * statement and a read of the landed row run as one unit of work, and the - * read's failure undoes the write. - * - * # The read, and why its absence is exact - * - * The landed row is looked up by the values it matched on (`matchOn`, the - * conflict-key values the statement sent — or `id` when one of them is - * empty, the same choice the read-back makes), under the written tenant - * EXACTLY: {@link applyTenantScope} for the chokepoint, plus equality on the - * tenant column, which closes that scope's NULL-organization arm. The tenant - * column is insert-only ({@link insertOnlyUpsertColumns}), so a merge never - * writes the written tenant onto the row it lands on. Therefore a row - * matching those values under the written tenant exists exactly when the - * statement inserted there or merged into a row that was already there; its - * absence means the merge landed on a row of another organization, or on a - * row with none. - * - * # When #8807's check is owed too (`rivals` given) - * - * A primary-key target on a table carrying a rival UNIQUE key. `matchOn` is - * then the supplied `id`, so a found row is also #8807's verdict — the merge - * landed on the supplied identity — and that check is subsumed. A MISSING row - * has two causes the caller must be told apart, because they are two - * different refusals: - * - * - the merge landed through a rival key on a row of the WRITTEN - * organization — #8807's condition, answered with its own sentence - * ({@link refuseCrossRowIdentityMerge}), exactly as before this card; - * - it landed on a row of another organization — on the primary key (that - * row wins a multi-key collision, measured for #8807) or through a rival - * key — answered `UNIQUE_VIOLATION`. - * - * They are told apart by reading each rival key's sent values under the - * written tenant: a merge through that key left the row carrying them, in - * the written organization. Only a rival whose every column is in the - * payload can be read this way (a hash-shadow carrier's generated column is - * not); when none can, the answer is `UNIQUE_VIOLATION` — a refusal either - * way, and the write is rolled back either way. - * - * ⛔ Nothing read here reaches the caller: the refusal names no organization - * and no value of the row it landed on. - */ + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts`, its docblock with it (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.assertMergeLandedOnSuppliedIdentity.call(sequenceSemanticsHost.of(this), object, writeTable, id, rivals, writtenTenant, options); + } + private assertMergeLandedInWrittenOrganization( object: string, writeTable: string, @@ -4141,14 +3060,8 @@ export class SqlDriver implements IDataDriver { identity: { id: string | number; rivals: PhysicalIndex[] } | null, options?: DriverOptions, ): Promise { - // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). - const readPhysicalTableByObject = () => this.physicalTableByObject; - const host: ThisParameterType = { - applyTenantScope: (...a) => this.applyTenantScope(...a), - getBuilder: this.getBuilder.bind(this), - get physicalTableByObject() { return readPhysicalTableByObject(); }, - }; - return assertMergeLandedInWrittenOrganization.call(host, object, writeTable, mergeKeys, matchOn, sent, guard, identity, options); + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts`, its docblock with it (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.assertMergeLandedInWrittenOrganization.call(sequenceSemanticsHost.of(this), object, writeTable, mergeKeys, matchOn, sent, guard, identity, options); } // [commit be5c60291] The return is the contract's own type. It was `Promise>`, and the @@ -4160,53 +3073,12 @@ export class SqlDriver implements IDataDriver { // `sql-driver-doors-declared-types.test.ts`. upsert(object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). - const readIsMysql = () => this.isMysql; - const readKnex = () => this.knex; - const host: ThisParameterType = { - applyTenantScope: (...a) => this.applyTenantScope(...a), - applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), - assertConflictTargetBackedWithoutClause: (...a) => this.assertConflictTargetBackedWithoutClause(...a), - assertConflictTargetHonoured: (...a) => this.assertConflictTargetHonoured(...a), - assertMergeLandedInWrittenOrganization: (...a) => this.assertMergeLandedInWrittenOrganization(...a), - assertMergeLandedOnSuppliedIdentity: (...a) => this.assertMergeLandedOnSuppliedIdentity(...a), - auditMissingTenant: (...a) => this.auditMissingTenant(...a), - collidingAutoNumberReservations: (...a) => this.collidingAutoNumberReservations(...a), - fillAutoNumberFields: (...a) => this.fillAutoNumberFields(...a), - formatInput: (...a) => this.formatInput(...a), - formatOutput: (...a) => this.formatOutput(...a), - getBuilder: this.getBuilder.bind(this), - injectTenantOnInsert: (...a) => this.injectTenantOnInsert(...a), - insertOnlyUpsertColumns: (...a) => this.insertOnlyUpsertColumns(...a), - get isMysql() { return readIsMysql(); }, - get knex() { return readKnex(); }, - mergeLandsInWrittenOrganization: (...a) => this.mergeLandsInWrittenOrganization(...a), - resolveTenantField: (...a) => this.resolveTenantField(...a), - resyncSequenceToDataMax: (...a) => this.resyncSequenceToDataMax(...a), - rotationWriteTarget: (...a) => this.rotationWriteTarget(...a), - stampInsertTimestamps: (...a) => this.stampInsertTimestamps(...a), - stampUpsertUpdatedAt: (...a) => this.stampUpsertUpdatedAt(...a), - upsertTenantGuard: (...a) => this.upsertTenantGuard(...a), - }; - return upsert.call(host, object, data, conflictKeys, options); - } - - async delete(object: string, id: string | number, options?: DriverOptions): Promise { - this.auditMissingTenant(object, 'delete', options); - const rotationShards = this.rotationShardsOf(object); - if (rotationShards) { - // The row lives in exactly one shard — probe newest-first. - for (const shard of rotationShards) { - const builder = this.getBuilder(shard, options).where('id', id); - this.applyTenantScope(builder, object, options); - const count = await builder.delete(); - if (count > 0) return true; - } - return false; - } - const builder = this.getBuilder(object, options).where('id', id); - this.applyTenantScope(builder, object, options); - const count = await builder.delete(); - return count > 0; + return SequenceSemanticsFacet.upsert.call(sequenceSemanticsHost.of(this), object, data, conflictKeys, options); + } + + delete(object: string, id: string | number, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-sequence-semantics.ts` (see `SequenceSemanticsHost`). + return SequenceSemanticsFacet.delete.call(sequenceSemanticsHost.of(this), object, id, options); } // =================================== @@ -4222,24 +3094,7 @@ export class SqlDriver implements IDataDriver { */ bulkCreate(object: string, data: any[], options?: DriverOptions): Promise[]> { // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). - const readInsertReturnsStoredRows = () => this.insertReturnsStoredRows; - const host: ThisParameterType = { - applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), - auditMissingTenant: (...a) => this.auditMissingTenant(...a), - autoNumberCounterKey: (...a) => this.autoNumberCounterKey(...a), - collidingAutoNumberReservations: (...a) => this.collidingAutoNumberReservations(...a), - fillAutoNumberFields: (...a) => this.fillAutoNumberFields(...a), - formatInput: (...a) => this.formatInput(...a), - formatOutput: (...a) => this.formatOutput(...a), - getBuilder: this.getBuilder.bind(this), - injectTenantOnInsert: (...a) => this.injectTenantOnInsert(...a), - get insertReturnsStoredRows() { return readInsertReturnsStoredRows(); }, - readBackInsertedRows: (...a) => this.readBackInsertedRows(...a), - resyncSequenceToDataMax: (...a) => this.resyncSequenceToDataMax(...a), - rotationWriteTarget: (...a) => this.rotationWriteTarget(...a), - stampInsertTimestamps: (...a) => this.stampInsertTimestamps(...a), - }; - return bulkCreate.call(host, object, data, options); + return BulkOperationsFacet.bulkCreate.call(bulkOperationsHost.of(this), object, data, options); } /** @@ -4305,21 +3160,12 @@ export class SqlDriver implements IDataDriver { // `sql-driver-doors-declared-types.test.ts`. bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record }>, options?: DriverOptions): Promise[]> { // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). - const readKnex = () => this.knex; - const host: ThisParameterType = { - get knex() { return readKnex(); }, - update: (...a) => this.update(...a), - }; - return bulkUpdate.call(host, object, updates, options); + return BulkOperationsFacet.bulkUpdate.call(bulkOperationsHost.of(this), object, updates, options); } - async bulkDelete(object: string, ids: Array, options?: DriverOptions): Promise { - this.auditMissingTenant(object, 'bulkDelete', options); - for (const target of this.rotationShardsOf(object) ?? [object]) { - const builder = this.getBuilder(target, options).whereIn('id', ids); - this.applyTenantScope(builder, object, options); - await builder.delete(); - } + bulkDelete(object: string, ids: Array, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). + return BulkOperationsFacet.bulkDelete.call(bulkOperationsHost.of(this), object, ids, options); } /** @@ -4374,33 +3220,12 @@ export class SqlDriver implements IDataDriver { */ updateMany(object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise { // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). - const host: ThisParameterType = { - applyFilters: (...a) => this.applyFilters(...a), - applyTenantScope: (...a) => this.applyTenantScope(...a), - applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), - auditMissingTenant: (...a) => this.auditMissingTenant(...a), - formatInput: (...a) => this.formatInput(...a), - getBuilder: this.getBuilder.bind(this), - keepSuppliedUpdatedAt: (...a) => this.keepSuppliedUpdatedAt(...a), - rotationShardsOf: (...a) => this.rotationShardsOf(...a), - stampsUpdatedAt: (...a) => this.stampsUpdatedAt(...a), - updateWithPresumedTimestamp: (...a) => this.updateWithPresumedTimestamp(...a), - updatedAtStamp: (...a) => this.updatedAtStamp(...a), - updatedAtStampIsPresumed: (...a) => this.updatedAtStampIsPresumed(...a), - }; - return updateMany.call(host, object, query, data, options); + return BulkOperationsFacet.updateMany.call(bulkOperationsHost.of(this), object, query, data, options); } - async deleteMany(object: string, query: DriverQuery, options?: DriverOptions): Promise { - this.auditMissingTenant(object, 'deleteMany', options); - let total = 0; - for (const target of this.rotationShardsOf(object) ?? [object]) { - const builder = this.getBuilder(target, options); - this.applyTenantScope(builder, object, options); - if (query.where) this.applyFilters(builder, query.where, options); - total += (await builder.delete()) || 0; - } - return total; + deleteMany(object: string, query: DriverQuery, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). + return BulkOperationsFacet.deleteMany.call(bulkOperationsHost.of(this), object, query, options); } /** By-id update for a rotation-managed object: the row lives in exactly one @@ -4413,17 +3238,7 @@ export class SqlDriver implements IDataDriver { options?: DriverOptions, ): Promise | null> { // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). - const host: ThisParameterType = { - applyTenantScope: (...a) => this.applyTenantScope(...a), - applyWriteColumnMap: (...a) => this.applyWriteColumnMap(...a), - formatInput: (...a) => this.formatInput(...a), - formatOutput: (...a) => this.formatOutput(...a), - getBuilder: this.getBuilder.bind(this), - keepSuppliedUpdatedAt: (...a) => this.keepSuppliedUpdatedAt(...a), - stampsUpdatedAt: (...a) => this.stampsUpdatedAt(...a), - updatedAtStamp: (...a) => this.updatedAtStamp(...a), - }; - return rotatedUpdateById.call(host, object, shards, id, data, options); + return BulkOperationsFacet.rotatedUpdateById.call(bulkOperationsHost.of(this), object, shards, id, data, options); } /** @@ -4526,14 +3341,7 @@ export class SqlDriver implements IDataDriver { count(object: string, query?: DriverQuery, options?: DriverOptions): Promise { // [#29520 codemod] The body moved to `sql-driver-bulk-operations.ts` (see `BulkOperationsHost`). - const host: ThisParameterType = { - applyFilters: (...a) => this.applyFilters(...a), - applyTenantScope: (...a) => this.applyTenantScope(...a), - backendStatementFault: (...a) => this.backendStatementFault(...a), - getBuilder: this.getBuilder.bind(this), - unresolvableFilterColumnRefusal: (...a) => this.unresolvableFilterColumnRefusal(...a), - }; - return count.call(host, object, query, options); + return BulkOperationsFacet.count.call(bulkOperationsHost.of(this), object, query, options); } // =================================== @@ -4654,11 +3462,8 @@ export class SqlDriver implements IDataDriver { /** Idempotently drop a transaction from the open-count (safe on double close). */ protected releaseTransaction(transaction: unknown): void { - const key = transaction as object; - if (key && this.openTransactions.has(key)) { - this.openTransactions.delete(key); - this.activeTransactions = Math.max(0, this.activeTransactions - 1); - } + // [#29520 codemod] The body moved to `sql-driver-transactions-section.ts` (see `TransactionsSectionHost`). + return TransactionsSectionFacet.releaseTransaction.call(transactionsSectionHost.of(this), transaction); } /** @@ -4754,29 +3559,7 @@ export class SqlDriver implements IDataDriver { */ aggregate(object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { // [#29520 codemod] The body moved to `sql-driver-aggregation.ts` (see `AggregationHost`). - const readConfig = () => this.config; - const readDateGranularityCapabilities = () => this.dateGranularityCapabilities; - const readDialectName = () => this.dialectName; - const readKnex = () => this.knex; - const host: ThisParameterType = { - aggregandColumnClassOf: (...a) => this.aggregandColumnClassOf(...a), - aggregateBackendFault: (...a) => this.aggregateBackendFault(...a), - aliasIdentifierSql: (...a) => this.aliasIdentifierSql(...a), - applyFilters: (...a) => this.applyFilters(...a), - applyTenantScope: (...a) => this.applyTenantScope(...a), - buildDateBucketExpr: (...a) => this.buildDateBucketExpr(...a), - coercionKey: (...a) => this.coercionKey(...a), - get config() { return readConfig(); }, - get dateGranularityCapabilities() { return readDateGranularityCapabilities(); }, - get dialectName() { return readDialectName(); }, - foldEmptyAggregateAnswers: (...a) => this.foldEmptyAggregateAnswers(...a), - getBuilder: this.getBuilder.bind(this), - get knex() { return readKnex(); }, - mapAggregateFunc: (...a) => this.mapAggregateFunc(...a), - presentReadColumns: (...a) => this.presentReadColumns(...a), - readPresentationKind: (...a) => this.readPresentationKind(...a), - }; - return aggregate.call(host, object, query, options); + return AggregationFacet.aggregate.call(aggregationHost.of(this), object, query, options); } /** @@ -4795,11 +3578,8 @@ export class SqlDriver implements IDataDriver { * `avg` already add doubles. */ protected aggregandColumnClassOf(table: string | null, field: string): AggregandColumnClass | undefined { - if (table === null) return undefined; - if (this.booleanFields[table]?.includes(field)) return 'boolean'; - if (this.fractionalNumericFields[table]?.includes(field)) return 'fractional'; - if (this.numericFields[table]?.includes(field)) return 'integral'; - return undefined; + // [#29520 codemod] The body moved to `sql-driver-aggregation.ts` (see `AggregationHost`). + return AggregationFacet.aggregandColumnClassOf.call(aggregationHost.of(this), table, field); } /** @@ -4846,7 +3626,7 @@ export class SqlDriver implements IDataDriver { */ protected foldEmptyAggregateAnswers(rows: any, identities: Map): any { // [#29520 codemod] The body moved to `sql-driver-aggregation.ts`. - return foldEmptyAggregateAnswers(rows, identities); + return AggregationFacet.foldEmptyAggregateAnswers(rows, identities); } /** @@ -5008,16 +3788,7 @@ export class SqlDriver implements IDataDriver { */ distinct(object: string, field: string, filters?: FilterCondition, options?: DriverOptions): Promise { // [#29520 codemod] The body moved to `sql-driver-distinct.ts` (see `DistinctHost`). - const host: ThisParameterType = { - applyFilters: (...a) => this.applyFilters(...a), - applyTenantScope: (...a) => this.applyTenantScope(...a), - coercionKey: (...a) => this.coercionKey(...a), - distinctBackendFault: (...a) => this.distinctBackendFault(...a), - getBuilder: this.getBuilder.bind(this), - presentReadValue: (...a) => this.presentReadValue(...a), - readPresentationKind: (...a) => this.readPresentationKind(...a), - }; - return distinct.call(host, object, field, filters, options); + return DistinctFacet.distinct.call(distinctHost.of(this), object, field, filters, options); } /** @@ -5177,18 +3948,7 @@ export class SqlDriver implements IDataDriver { */ findWithWindowFunctions(object: string, query: SqlWindowFunctionQuery, options?: DriverOptions): Promise { // [#29520 codemod] The body moved to `sql-driver-window-functions.ts` (see `WindowFunctionsHost`). - const readKnex = () => this.knex; - const host: ThisParameterType = { - aliasIdentifierSql: (...a) => this.aliasIdentifierSql(...a), - applyFilters: (...a) => this.applyFilters(...a), - applyTenantScope: (...a) => this.applyTenantScope(...a), - buildWindowFunction: (...a) => this.buildWindowFunction(...a), - formatOutput: (...a) => this.formatOutput(...a), - getBuilder: this.getBuilder.bind(this), - get knex() { return readKnex(); }, - mapSortField: (...a) => this.mapSortField(...a), - }; - return findWithWindowFunctions.call(host, object, query, options); + return WindowFunctionsFacet.findWithWindowFunctions.call(windowFunctionsHost.of(this), object, query, options); } // =================================== @@ -5206,8 +3966,9 @@ export class SqlDriver implements IDataDriver { * off-contract helper it forwards to, keeps its own annotation — it is not an * `IDataDriver` door and is out of that card's scope. */ - async explain(object: string, query: DriverQuery, options?: DriverOptions): Promise { - return this.analyzeQuery(object, query, options); + explain(object: string, query: DriverQuery, options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-query-plan.ts` (see `QueryPlanHost`). + return QueryPlanFacet.explain.call(queryPlanHost.of(this), object, query, options); } /** @@ -5218,40 +3979,21 @@ export class SqlDriver implements IDataDriver { */ analyzeQuery(object: string, query: DriverQuery, options?: DriverOptions): Promise { // [#29520 codemod] The body moved to `sql-driver-query-plan.ts` (see `QueryPlanHost`). - const readConfig = () => this.config; - const readIsMysql = () => this.isMysql; - const readIsPostgres = () => this.isPostgres; - const readIsSqlite = () => this.isSqlite; - const readKnex = () => this.knex; - const host: ThisParameterType = { - applyFilters: (...a) => this.applyFilters(...a), - applyTenantScope: (...a) => this.applyTenantScope(...a), - get config() { return readConfig(); }, - getBuilder: this.getBuilder.bind(this), - get isMysql() { return readIsMysql(); }, - get isPostgres() { return readIsPostgres(); }, - get isSqlite() { return readIsSqlite(); }, - get knex() { return readKnex(); }, - mapSortField: (...a) => this.mapSortField(...a), - }; - return analyzeQuery.call(host, object, query, options); + return QueryPlanFacet.analyzeQuery.call(queryPlanHost.of(this), object, query, options); } // =================================== // Schema Sync (syncSchema / init) // =================================== - async syncSchema(object: string, schema: unknown, _options?: DriverOptions): Promise { - const objectDef = schema as { name: string; fields?: Record }; - // The caller passes the resolved physical table name as `object`. Override - // the def's `name` to ensure DDL targets the physical table even if the - // schema's `name` is the canonical object name (e.g. 'account'). - await this.initObjects([{ ...objectDef, name: object }]); + syncSchema(object: string, schema: unknown, _options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-schema-sync-section.ts` (see `SchemaSyncSectionHost`). + return SchemaSyncSectionFacet.syncSchema.call(schemaSyncSectionHost.of(this), object, schema, _options); } - async dropTable(object: string, _options?: DriverOptions): Promise { - this.assertSchemaMutable('dropTable'); - await this.knex.schema.dropTableIfExists(object); + dropTable(object: string, _options?: DriverOptions): Promise { + // [#29520 codemod] The body moved to `sql-driver-schema-sync-section.ts` (see `SchemaSyncSectionHost`). + return SchemaSyncSectionFacet.dropTable.call(schemaSyncSectionHost.of(this), object, _options); } /** @@ -5283,13 +4025,7 @@ export class SqlDriver implements IDataDriver { */ reclaimSpace(_options?: DriverOptions): Promise { // [#29520 codemod] The body moved to `sql-driver-schema-sync-section.ts` (see `SchemaSyncSectionHost`). - const readIsSqlite = () => this.isSqlite; - const readKnex = () => this.knex; - const host: ThisParameterType = { - get isSqlite() { return readIsSqlite(); }, - get knex() { return readKnex(); }, - }; - return reclaimSpace.call(host, _options); + return SchemaSyncSectionFacet.reclaimSpace.call(schemaSyncSectionHost.of(this), _options); } // ── Data-lifecycle rotation (ADR-0057 P2) ───────────────────────────────── @@ -5318,12 +4054,14 @@ export class SqlDriver implements IDataDriver { /** Live shard set (newest first) when `object` is rotation-managed. */ protected rotationShardsOf(object: string): string[] | undefined { - return this.rotationStateByTable.get(object)?.shards; + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.rotationShardsOf.call(rotationHost.of(this), object); } /** The shard new rows land in when `object` is rotation-managed. */ protected rotationWriteTarget(object: string): string | undefined { - return this.rotationStateByTable.get(object)?.current; + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.rotationWriteTarget.call(rotationHost.of(this), object); } /** @@ -5343,20 +4081,12 @@ export class SqlDriver implements IDataDriver { * shard, column-syncs every retained shard so the UNION stays uniform, and * drops shards past the `shards × unit` window. */ - async rotateShards( + rotateShards( objectDef: { name: string; fields?: Record; tenancy?: any; indexes?: any[]; lifecycle?: any }, nowMs: number = Date.now(), ): Promise<{ object: string; current: string; shards: string[]; dropped: string[] }> { - this.assertSchemaMutable('rotateShards'); - const policy = objectDef.lifecycle?.storage; - if (!policy || policy.strategy !== 'rotation') { - throw new Error(`[sql-driver] rotateShards: '${objectDef.name}' declares no lifecycle.storage rotation policy`); - } - if (!this.supportsRotation) { - throw new Error(`[sql-driver] rotateShards: rotation is not supported on dialect '${this.dialectName}'`); - } - const tableName = StorageNameMapping.resolveTableName(objectDef as any); - return this.ensureRotation(tableName, objectDef, policy, nowMs); + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.rotateShards.call(rotationHost.of(this), objectDef, nowMs); } protected ensureRotation( @@ -5551,20 +4281,8 @@ export class SqlDriver implements IDataDriver { * targeting the view. */ protected aliasShardBookkeeping(base: string, shard: string): void { - this.jsonFields[shard] = this.jsonFields[base] ?? []; - this.valueShapeFields[shard] = this.valueShapeFields[base] ?? {}; - this.mediaFields[shard] = this.mediaFields[base] ?? []; - this.booleanFields[shard] = this.booleanFields[base] ?? []; - this.numericFields[shard] = this.numericFields[base] ?? []; - this.numericValueFields[shard] = this.numericValueFields[base] ?? []; - this.fractionalNumericFields[shard] = this.fractionalNumericFields[base] ?? []; - this.autoNumberFields[shard] = this.autoNumberFields[base] ?? []; - if (this.dateFields[base]) this.dateFields[shard] = this.dateFields[base]; - if (this.datetimeFields[base]) this.datetimeFields[shard] = this.datetimeFields[base]; - if (this.timeFields[base]) this.timeFields[shard] = this.timeFields[base]; - this.tenantFieldByTable[shard] = this.tenantFieldByTable[base] ?? null; - if (this.tenantOptOutByTable.has(base)) this.tenantOptOutByTable.add(shard); - this.tablesWithTimestamps.add(shard); + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.aliasShardBookkeeping.call(rotationHost.of(this), base, shard); } /** @@ -5589,7 +4307,7 @@ export class SqlDriver implements IDataDriver { */ protected computeTenantField(schema: { fields?: Record; tenancy?: any }): string | null { // [#29520 codemod] The body moved to `sql-driver-rotation.ts`. - return computeTenantField(schema); + return RotationFacet.computeTenantField(schema); } /** @@ -5609,14 +4327,8 @@ export class SqlDriver implements IDataDriver { key: string, schema: { fields?: Record; tenancy?: any }, ): string | null { - if (key === SystemObjectName.ORGANIZATION) this.organizationObjectRegistered = true; - if (schema?.tenancy != null) { - if (isTenancyDisabled(schema)) this.tenantOptOutByTable.add(key); - else this.tenantOptOutByTable.delete(key); - return this.computeTenantField(schema); - } - if (this.tenantOptOutByTable.has(key)) return null; - return this.computeTenantField(schema); + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.computeAndRecordTenantField.call(rotationHost.of(this), key, schema); } /** @@ -5695,57 +4407,19 @@ export class SqlDriver implements IDataDriver { * installed here: the JSON / boolean / numeric / date / datetime / time * coercion registries, the auto_number descriptors, the tenant-isolation * column, and the `managedObjectFields` / `managedObjectIndexes` entries - * drift detection diffs against. {@link SqlDriver.initObjects} calls it as - * its first step and then issues DDL; {@link registerObjectMetadata} calls - * it and stops. - * - * @returns the physical table name registered, and the tenant column - * {@link computeAndRecordTenantField} resolved for it — both of - * which `initObjects` goes on to use for its DDL. - */ - protected registerManagedObjectMetadata( - obj: { name: string; fields?: Record; tenancy?: any; indexes?: any[] }, - ): { tableName: string; tenantField: string | null } { - // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). - const readAutoNumberFields = () => this.autoNumberFields; - const readBooleanFields = () => this.booleanFields; - const readDateFields = () => this.dateFields; - const readDatetimeFields = () => this.datetimeFields; - const readFractionalNumericFields = () => this.fractionalNumericFields; - const readJsonFields = () => this.jsonFields; - const readManagedObjectFields = () => this.managedObjectFields; - const readManagedObjectIndexes = () => this.managedObjectIndexes; - const readMediaFields = () => this.mediaFields; - const readNumericFields = () => this.numericFields; - const readNumericValueFields = () => this.numericValueFields; - const readPhysicalKeyIndexes = () => this.physicalKeyIndexes; - const readTenantFieldByTable = () => this.tenantFieldByTable; - const readTimeFields = () => this.timeFields; - const readUndeclaredAuditTimestampFields = () => this.undeclaredAuditTimestampFields; - const readUpdatedAtColumnState = () => this.updatedAtColumnState; - const readValueShapeFields = () => this.valueShapeFields; - const host: ThisParameterType = { - get autoNumberFields() { return readAutoNumberFields(); }, - get booleanFields() { return readBooleanFields(); }, - computeAndRecordTenantField: (...a) => this.computeAndRecordTenantField(...a), - get dateFields() { return readDateFields(); }, - get datetimeFields() { return readDatetimeFields(); }, - get fractionalNumericFields() { return readFractionalNumericFields(); }, - isJsonField: (...a) => this.isJsonField(...a), - get jsonFields() { return readJsonFields(); }, - get managedObjectFields() { return readManagedObjectFields(); }, - get managedObjectIndexes() { return readManagedObjectIndexes(); }, - get mediaFields() { return readMediaFields(); }, - get numericFields() { return readNumericFields(); }, - get numericValueFields() { return readNumericValueFields(); }, - get physicalKeyIndexes() { return readPhysicalKeyIndexes(); }, - get tenantFieldByTable() { return readTenantFieldByTable(); }, - get timeFields() { return readTimeFields(); }, - get undeclaredAuditTimestampFields() { return readUndeclaredAuditTimestampFields(); }, - get updatedAtColumnState() { return readUpdatedAtColumnState(); }, - get valueShapeFields() { return readValueShapeFields(); }, - }; - return registerManagedObjectMetadata.call(host, obj); + * drift detection diffs against. {@link SqlDriver.initObjects} calls it as + * its first step and then issues DDL; {@link registerObjectMetadata} calls + * it and stops. + * + * @returns the physical table name registered, and the tenant column + * {@link computeAndRecordTenantField} resolved for it — both of + * which `initObjects` goes on to use for its DDL. + */ + protected registerManagedObjectMetadata( + obj: { name: string; fields?: Record; tenancy?: any; indexes?: any[] }, + ): { tableName: string; tenantField: string | null } { + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.registerManagedObjectMetadata.call(rotationHost.of(this), obj); } /** @@ -5794,7 +4468,8 @@ export class SqlDriver implements IDataDriver { registerObjectMetadata( objects: Array<{ name: string; fields?: Record; tenancy?: any; indexes?: any[] }>, ): void { - for (const obj of objects) this.registerManagedObjectMetadata(obj); + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return RotationFacet.registerObjectMetadata.call(rotationHost.of(this), objects); } // `tenancy` is part of what this method READS — each object flows into @@ -6476,11 +5151,11 @@ export class SqlDriver implements IDataDriver { * without stubbing any of the bounding, recognition or refusal logic above — * the parts that must be exercised for real. */ - protected async withPinnedSession( + protected withPinnedSession( fn: (run: (sql: string, bindings?: unknown[]) => Promise) => Promise, ): Promise { - return await this.knex.transaction(async (trx) => - fn((sql, bindings) => trx.raw(sql, (bindings ?? []) as any))); + // [#29520 codemod] The body moved to `sql-driver-rotation.ts` (see `RotationHost`). + return (RotationFacet.withPinnedSession).call(rotationHost.of(this), fn); } protected async migrateMysqlDatetimeColumns( @@ -6654,7 +5329,8 @@ export class SqlDriver implements IDataDriver { * to run exactly as before. */ setDeferredDdl(deferred: boolean): void { - this.deferredDdl = deferred; + // [#29520 codemod] The body moved to `sql-driver-deferred-ddl.ts` (see `DeferredDdlHost`). + return DeferredDdlFacet.setDeferredDdl.call(deferredDdlHost.of(this), deferred); } /** How many objects are waiting for {@link flushDeferredSchemaDdl}. */ @@ -6669,7 +5345,8 @@ export class SqlDriver implements IDataDriver { * empty when this process reached it (#3438 / ADR-0104). */ getSchemaSyncStats(): { created: number; existing: number } { - return { created: this.tablesCreatedHere.size, existing: this.tablesFoundExisting.size }; + // [#29520 codemod] The body moved to `sql-driver-deferred-ddl.ts` (see `DeferredDdlHost`). + return DeferredDdlFacet.getSchemaSyncStats.call(deferredDdlHost.of(this)); } /** @@ -6790,30 +5467,9 @@ export class SqlDriver implements IDataDriver { * outstanding (captured before the DDL ran, so the caller can report what it * just did). A no-op when nothing was deferred. */ - async flushDeferredSchemaDdl(): Promise { - const pending = [...this.deferredSchemaObjects.values()]; - if (pending.length === 0) { - this.deferredDdl = false; - return []; - } - const performed = await this.previewDeferredSchemaWork(); - this.deferredSchemaObjects.clear(); - this.deferredDdl = false; - // Re-entering initObjects re-registers the same metadata (idempotent) and - // this time takes the DDL path, so create/alter/index/rotation handling - // stays in exactly one place. - // - // #9354: armed across that re-entry, and cleared in a `finally` so a refusal - // thrown out of the widening cannot leave the flag set on a driver the - // caller goes on using (`os migrate apply` keeps the stack alive to shut it - // down). It is the only thing distinguishing this path from boot sync. - this.flushingDeferredDdl = true; - try { - await this.initObjects(pending); - } finally { - this.flushingDeferredDdl = false; - } - return performed; + flushDeferredSchemaDdl(): Promise { + // [#29520 codemod] The body moved to `sql-driver-deferred-ddl.ts` (see `DeferredDdlHost`). + return DeferredDdlFacet.flushDeferredSchemaDdl.call(deferredDdlHost.of(this)); } // ── Managed-schema drift & reconcile (#2186) ─────────────────────────────── @@ -6848,7 +5504,7 @@ export class SqlDriver implements IDataDriver { /** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */ protected isProductionEnv(): boolean { // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts`. - return isProductionEnv(); + return ManagedDriftFacet.isProductionEnv(); } /** @@ -6979,11 +5635,7 @@ export class SqlDriver implements IDataDriver { nullSafeColumns: string[], ): Promise> { // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts` (see `ManagedDriftHost`). - const readKnex = () => this.knex; - const host: ThisParameterType = { - get knex() { return readKnex(); }, - }; - return probeNullSafeUniqueDuplicates.call(host, table, columns, nullSafeColumns); + return ManagedDriftFacet.probeNullSafeUniqueDuplicates.call(managedDriftHost.of(this), table, columns, nullSafeColumns); } /** @@ -7265,7 +5917,7 @@ export class SqlDriver implements IDataDriver { */ protected buildRebuiltColumn(t: Knex.CreateTableBuilder, c: IntrospectedColumn): any { // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts`. - return buildRebuiltColumn(t, c); + return ManagedDriftFacet.buildRebuiltColumn(t, c); } /** @@ -7277,7 +5929,8 @@ export class SqlDriver implements IDataDriver { * *creates* and the names the drift differ *looks for* can never diverge. */ protected buildIndexName(tableName: string, fields: string[], unique: boolean): string { - return buildIndexName(tableName, fields, unique); + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts`. + return ManagedDriftFacet.buildIndexName(tableName, fields, unique); } /** @@ -7404,8 +6057,9 @@ export class SqlDriver implements IDataDriver { * a false absence keeps the legacy index in place and under-reports what was * applied, never the reverse. */ - protected async getExistingIndexNames(tableName: string): Promise> { - return new Set((await this.introspectIndexes(tableName, { onFailure: 'partial' })).map((i) => i.name)); + protected getExistingIndexNames(tableName: string): Promise> { + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts` (see `ManagedDriftHost`). + return ManagedDriftFacet.getExistingIndexNames.call(managedDriftHost.of(this), tableName); } /** @@ -7418,12 +6072,8 @@ export class SqlDriver implements IDataDriver { fields: Record, tenantField: string | null, ): Array<{ name: string; fields: string[]; unique: true; nullSafeColumns?: string[] }> { - return uniqueIndexesFromFields(tableName, fields, tenantField).map((i) => ({ - name: i.name, - fields: i.columns, - unique: true as const, - ...(i.nullSafeColumns ? { nullSafeColumns: i.nullSafeColumns } : {}), - })); + // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts`. + return ManagedDriftFacet.uniqueIndexesFromFields(tableName, fields, tenantField); } /** @@ -7439,15 +6089,7 @@ export class SqlDriver implements IDataDriver { */ protected dropIndexIfExists(tableName: string, indexName: string): Promise { // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts` (see `ManagedDriftHost`). - const readIsMysql = () => this.isMysql; - const readIsPostgres = () => this.isPostgres; - const readKnex = () => this.knex; - const host: ThisParameterType = { - get isMysql() { return readIsMysql(); }, - get isPostgres() { return readIsPostgres(); }, - get knex() { return readKnex(); }, - }; - return dropIndexIfExists.call(host, tableName, indexName); + return ManagedDriftFacet.dropIndexIfExists.call(managedDriftHost.of(this), tableName, indexName); } /** @@ -7475,11 +6117,7 @@ export class SqlDriver implements IDataDriver { physicalColumns: Set, ): Promise { // [#29520 codemod] The body moved to `sql-driver-managed-drift.ts` (see `ManagedDriftHost`). - const host: ThisParameterType = { - syncDeclaredIndexes: (...a) => this.syncDeclaredIndexes(...a), - uniqueIndexesFromFields: (...a) => this.uniqueIndexesFromFields(...a), - }; - return syncTableIndexes.call(host, tableName, fields, declaredIndexes, tenantField, physicalColumns); + return ManagedDriftFacet.syncTableIndexes.call(managedDriftHost.of(this), tableName, fields, declaredIndexes, tenantField, physicalColumns); } /** @@ -7650,7 +6288,8 @@ export class SqlDriver implements IDataDriver { /** Expose the underlying Knex instance for advanced usage. */ getKnex(): Knex { - return this.knex; + // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). + return InternalHelpersFacet.getKnex.call(internalHelpersHost.of(this)); } protected getBuilder(object: string, options?: DriverOptions) { @@ -7679,10 +6318,8 @@ export class SqlDriver implements IDataDriver { * Returns `null` when the object has no tenant-isolation field. */ protected resolveTenantField(object: string): string | null { - const tableName = StorageNameMapping.resolveTableName({ name: object } as any); - const cached = - this.tenantFieldByTable[tableName] ?? this.tenantFieldByTable[object]; - return cached ?? null; + // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). + return InternalHelpersFacet.resolveTenantField.call(internalHelpersHost.of(this), object); } /** @@ -7715,7 +6352,8 @@ export class SqlDriver implements IDataDriver { * early-out, so a normal tenant-scoped write never reaches this at all. */ protected isMultiTenantMode(): boolean { - return postureEnforcesWall(resolveTenancyPosture()); + // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts`. + return InternalHelpersFacet.isMultiTenantMode(); } /** @@ -7760,10 +6398,7 @@ export class SqlDriver implements IDataDriver { options?: DriverOptions, ): Knex.QueryBuilder { // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). - const host: ThisParameterType = { - resolveTenantField: (...a) => this.resolveTenantField(...a), - }; - return applyTenantScope.call(host, builder, object, options); + return InternalHelpersFacet.applyTenantScope.call(internalHelpersHost.of(this), builder, object, options); } /** @@ -7785,11 +6420,8 @@ export class SqlDriver implements IDataDriver { * transaction of its own: it is part of the outer statement, which does. */ protected readableIdsSubquery(leaf: ComposedReadableIds, options?: DriverOptions): Knex.QueryBuilder { - const parent = leaf.object; - const sub = this.getBuilder(parent).select(this.remoteColumn(parent, 'id', 'id')); - this.applyTenantScope(sub, parent, options); - if (leaf.scope !== undefined) this.applyFilters(sub, leaf.scope, options); - return sub; + // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). + return InternalHelpersFacet.readableIdsSubquery.call(internalHelpersHost.of(this), leaf, options); } /** @@ -7807,10 +6439,7 @@ export class SqlDriver implements IDataDriver { options?: DriverOptions, ): void { // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). - const host: ThisParameterType = { - resolveTenantField: (...a) => this.resolveTenantField(...a), - }; - return injectTenantOnInsert.call(host, object, row, options); + return InternalHelpersFacet.injectTenantOnInsert.call(internalHelpersHost.of(this), object, row, options); } /** @@ -7851,15 +6480,7 @@ export class SqlDriver implements IDataDriver { insertRows?: readonly unknown[], ): void { // [#29520 codemod] The body moved to `sql-driver-internal-helpers.ts` (see `InternalHelpersHost`). - const readLogger = () => this.logger; - const readTenantAuditWarned = () => this.tenantAuditWarned; - const host: ThisParameterType = { - isMultiTenantMode: (...a) => this.isMultiTenantMode(...a), - get logger() { return readLogger(); }, - resolveTenantField: (...a) => this.resolveTenantField(...a), - get tenantAuditWarned() { return readTenantAuditWarned(); }, - }; - return auditMissingTenant.call(host, object, op, options, insertRows); + return InternalHelpersFacet.auditMissingTenant.call(internalHelpersHost.of(this), object, op, options, insertRows); } // ── Filter helpers ────────────────────────────────────────────────────────── @@ -7871,7 +6492,7 @@ export class SqlDriver implements IDataDriver { */ protected tableNameForBuilder(builder: any): string | null { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return tableNameForBuilder(builder); + return FilterHelpersFacet.tableNameForBuilder(builder); } /** @@ -7884,9 +6505,8 @@ export class SqlDriver implements IDataDriver { * exact where it matters. */ protected coercionKey(builder: any): string | null { - const physical = this.tableNameForBuilder(builder); - if (physical == null) return null; - return this.objectByPhysicalTable[physical] ?? physical; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.coercionKey.call(filterHelpersHost.of(this), builder); } /** @@ -7932,7 +6552,7 @@ export class SqlDriver implements IDataDriver { */ protected toDateOnly(value: any): any { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return toDateOnly(value); + return FilterHelpersFacet.toDateOnly(value); } /** @@ -7947,7 +6567,8 @@ export class SqlDriver implements IDataDriver { * `.5` for the stored `.500`). */ protected toTimeOnly(value: any): any { - return canonicalTimeOfDay(value); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. + return FilterHelpersFacet.toTimeOnly(value); } /** @@ -7973,13 +6594,7 @@ export class SqlDriver implements IDataDriver { */ protected coerceFilterValue(table: string | null, field: string, value: any): any { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). - const host: ThisParameterType = { - coerceFilterValue: (...a) => this.coerceFilterValue(...a), - storageDatetimeValue: (...a) => this.storageDatetimeValue(...a), - temporalFieldKind: (...a) => this.temporalFieldKind(...a), - toDateOnly: (...a) => this.toDateOnly(...a), - }; - return coerceFilterValue.call(host, table, field, value); + return FilterHelpersFacet.coerceFilterValue.call(filterHelpersHost.of(this), table, field, value); } /** @@ -8016,14 +6631,13 @@ export class SqlDriver implements IDataDriver { * the canonical string as-is and Postgres parses it straight into `timestamptz`. */ protected storageDatetimeValue(value: unknown): unknown { - const canonical = canonicalUtcDatetime(value); - return this.isMysql ? mysqlDatetimeLiteral(canonical) : canonical; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.storageDatetimeValue.call(filterHelpersHost.of(this), value); } protected needsLegacyDatetimeRepair(table: string | null | undefined, field: string): boolean { - if (!table || !this.isSqlite) return false; - if (this.datetimeFields[table]?.has(field) !== true) return false; - return this.canonicalDatetimeFields[table]?.has(field) !== true; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.needsLegacyDatetimeRepair.call(filterHelpersHost.of(this), table, field); } /** @@ -8045,7 +6659,7 @@ export class SqlDriver implements IDataDriver { */ protected sqliteCanonicalDatetimeSql(columnSql: string): string { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return sqliteCanonicalDatetimeSql(columnSql); + return FilterHelpersFacet.sqliteCanonicalDatetimeSql(columnSql); } /** @@ -8102,7 +6716,7 @@ export class SqlDriver implements IDataDriver { */ protected sqliteNonTemporalTextSql(columnSql: string): string { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return sqliteNonTemporalTextSql(columnSql); + return FilterHelpersFacet.sqliteNonTemporalTextSql(columnSql); } /** @@ -8111,9 +6725,8 @@ export class SqlDriver implements IDataDriver { * therefore need {@link sqliteCanonicalTimeSql} wrapped around it? */ protected needsLegacyTimeRepair(table: string | null | undefined, field: string): boolean { - if (!table || !this.isSqlite) return false; - if (this.timeFields[table]?.has(field) !== true) return false; - return this.canonicalTimeFields[table]?.has(field) !== true; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.needsLegacyTimeRepair.call(filterHelpersHost.of(this), table, field); } /** @@ -8135,7 +6748,7 @@ export class SqlDriver implements IDataDriver { */ protected sqliteCanonicalTimeSql(columnSql: string): string { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`. - return sqliteCanonicalTimeSql(columnSql); + return FilterHelpersFacet.sqliteCanonicalTimeSql(columnSql); } /** @@ -8147,11 +6760,8 @@ export class SqlDriver implements IDataDriver { table: string | null | undefined, field: string, ): 'datetime' | 'date' | 'time' | null { - if (!table) return null; - if (this.datetimeFields[table]?.has(field)) return 'datetime'; - if (this.dateFields[table]?.has(field)) return 'date'; - if (this.timeFields[table]?.has(field)) return 'time'; - return null; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.temporalFieldKind.call(filterHelpersHost.of(this), table, field); } /** @@ -8198,18 +6808,7 @@ export class SqlDriver implements IDataDriver { field: string, ): ReadPresentationKind | null { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). - const readBooleanFields = () => this.booleanFields; - const readIsMysql = () => this.isMysql; - const readIsSqlite = () => this.isSqlite; - const readNumericFields = () => this.numericFields; - const host: ThisParameterType = { - get booleanFields() { return readBooleanFields(); }, - get isMysql() { return readIsMysql(); }, - get isSqlite() { return readIsSqlite(); }, - get numericFields() { return readNumericFields(); }, - temporalFieldKind: (...a) => this.temporalFieldKind(...a), - }; - return readPresentationKind.call(host, table, field); + return FilterHelpersFacet.readPresentationKind.call(filterHelpersHost.of(this), table, field); } /** @@ -8250,11 +6849,7 @@ export class SqlDriver implements IDataDriver { */ protected presentReadValue(kind: ReadPresentationKind, value: any): any { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). - const host: ThisParameterType = { - toDateOnly: (...a) => this.toDateOnly(...a), - toTimeOnly: (...a) => this.toTimeOnly(...a), - }; - return presentReadValue.call(host, kind, value); + return FilterHelpersFacet.presentReadValue.call(filterHelpersHost.of(this), kind, value); } /** @@ -8271,10 +6866,7 @@ export class SqlDriver implements IDataDriver { */ protected presentReadColumns(rows: any, columns: Map): any { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). - const host: ThisParameterType = { - presentReadValue: (...a) => this.presentReadValue(...a), - }; - return presentReadColumns.call(host, rows, columns); + return FilterHelpersFacet.presentReadColumns.call(filterHelpersHost.of(this), rows, columns); } /** @@ -8298,8 +6890,8 @@ export class SqlDriver implements IDataDriver { field: string, table: string | null | undefined, ): { sql: string; bindings: any[] } { - if (!this.needsLegacyDatetimeRepair(table, field)) return { sql: '??', bindings: [field] }; - return { sql: this.sqliteCanonicalDatetimeSql('??'), bindings: [field, field, field, field] }; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.sqliteTemporalArg.call(filterHelpersHost.of(this), field, table); } /** @@ -8318,13 +6910,7 @@ export class SqlDriver implements IDataDriver { column: string, ): { sql: string; bindings: any[] } | null { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). - const host: ThisParameterType = { - needsLegacyDatetimeRepair: (...a) => this.needsLegacyDatetimeRepair(...a), - needsLegacyTimeRepair: (...a) => this.needsLegacyTimeRepair(...a), - sqliteCanonicalDatetimeSql: (...a) => this.sqliteCanonicalDatetimeSql(...a), - sqliteCanonicalTimeSql: (...a) => this.sqliteCanonicalTimeSql(...a), - }; - return filterColumnExpr.call(host, table, field, column); + return FilterHelpersFacet.filterColumnExpr.call(filterHelpersHost.of(this), table, field, column); } /** @@ -8386,7 +6972,8 @@ export class SqlDriver implements IDataDriver { // `ReturnType` rather than `Resolved` in // `sql-driver-doors-declared-types.test.ts`. public temporalFilterValue(objectName: string, field: string, value: any): unknown { - return this.coerceFilterValue(objectName, field, value); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.temporalFilterValue.call(filterHelpersHost.of(this), objectName, field, value); } /** @@ -8404,13 +6991,8 @@ export class SqlDriver implements IDataDriver { * must wrap its column with this too, or it keeps half the bug. */ public temporalFilterColumnSql(objectName: string, field: string, columnSql: string): string { - if (this.needsLegacyDatetimeRepair(objectName, field)) { - return this.sqliteCanonicalDatetimeSql(columnSql); - } - if (this.needsLegacyTimeRepair(objectName, field)) { - return this.sqliteCanonicalTimeSql(columnSql); - } - return columnSql; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.temporalFilterColumnSql.call(filterHelpersHost.of(this), objectName, field, columnSql); } /** @@ -8436,8 +7018,8 @@ export class SqlDriver implements IDataDriver { * The gate fires only where the column type is KNOWN to be JSON. */ protected isJsonColumn(table: string | null | undefined, localField: string): boolean { - if (!table) return false; - return this.jsonFields[table]?.includes(localField) === true; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.isJsonColumn.call(filterHelpersHost.of(this), table, localField); } /** @@ -8460,8 +7042,8 @@ export class SqlDriver implements IDataDriver { * method, so both faces of one `TursoDriver` word one refusal. */ protected jsonColumnFieldClass(table: string | null | undefined, localField: string): JsonColumnFieldClass | undefined { - if (!table || !this.isJsonColumn(table, localField)) return undefined; - return this.mediaFields[table]?.includes(localField) === true ? 'single-value-media' : 'multi-value-or-json'; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.jsonColumnFieldClass.call(filterHelpersHost.of(this), table, localField); } /** @@ -8546,63 +7128,13 @@ export class SqlDriver implements IDataDriver { * rather than moving this answer. */ protected isNonTextColumn(table: string | null | undefined, localField: string): boolean { - if (!table) return false; - return ( - this.numericFields[table]?.includes(localField) === true || - (this.booleanFields[table]?.includes(localField) === true && !this.isJsonColumn(table, localField)) || - (this.temporalFieldKind(table, localField) !== null && !this.isJsonColumn(table, localField)) - ); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.isNonTextColumn.call(filterHelpersHost.of(this), table, localField); } - /** - * [#14079] Emit the DECLARED answer for a text operator aimed at a column - * whose stored value is never text ({@link isNonTextColumn}): the positive - * operators (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / - * `$like` / `$ilike`) compile to the FALSE constant — no row's number - * contains a substring — and `$notContains` to the TRUE constant, its exact - * complement. `FILTER_TEXT_CASES`' `score` rows are the pin, on every face. - * - * # What compiled before, per dialect — three answers to one filter - * - * Measured on `origin/main` over a `Field.number` column holding `5` - * (#14079): the SQLite dialects compiled `col GLOB '*5*'` and COERCED the - * REAL to text in the storage class's own spelling (`'5.0'`, so - * `$contains: '.0'` matched and `$endsWith: '5'` did not); live Postgres - * compiled `col LIKE $1 ESCAPE $2` and REFUSED at query time with SQLSTATE - * 42883 (`operator does not exist: real ~~ text`) — a 500 for a filter the - * spec accepts; MySQL's `CAST(col AS BINARY) LIKE ?` reads as coercion (not - * executed — no server was provisionable). The maintainer ruled the cell on - * 2026-09-05 (option A, type-gate; coercion refused on the measurement) and - * the constants are that ruling compiled: the same row set on every dialect, - * decided by the DECLARED type at compile time because the stored value is - * not visible until run time. - * - * # Why constants compose with the NULL rules rather than fight them - * - * A row with no value already satisfies `$notContains` (#5298, - * {@link SqlDriver.applyNullSafeNegative}) and fails every positive - * operator, so `1 = 1` / `1 = 0` agree with the null polarity on every row — - * there is no cell where the two rules disagree. Under `$not` the leaf is - * totalised first (the shared lowering's rule 3, at the seams): `NOT (col IS NOT NULL - * AND 1 = 0)` is TRUE for every row, which is what the JS faces answer for - * `!contains` on a number, and `NOT (col IS NULL OR 1 = 1)` is FALSE for - * every row, what they answer for `!notContains`. Complementarity holds - * through the negation too. - * - * Every refusal stays AHEAD of this: the comparand gates on the validating - * walk (`reduceFilterKey`: an empty or non-string `$icontains` comparand, a - * non-string or dangling `$like` pattern) and {@link assertCompilableComparand} - * all run before the caller reaches here, so a filter the contract refuses - * is still refused — the constant answers only a filter the contract - * accepts. `1 = 0` is {@link applyFalseConstant}'s spelling; `1 = 1` is its - * mirror, valid on every dialect this driver targets and free of bindings. - */ private applyTextOperatorOverNonTextColumn(builder: any, logicalOp: 'and' | 'or', op: string): void { - if (op === '$notContains') { - builder[logicalOp === 'or' ? 'orWhereRaw' : 'whereRaw']('1 = 1'); - return; - } - this.applyFalseConstant(builder, logicalOp); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`, its docblock with it (see `FilterHelpersHost`). + return FilterHelpersFacet.applyTextOperatorOverNonTextColumn.call(filterHelpersHost.of(this), builder, logicalOp, op); } /** @@ -8613,9 +7145,8 @@ export class SqlDriver implements IDataDriver { * question through this method, so its two faces read one registry. */ protected declaredValueShape(table: string | null | undefined, localField: string): ValueShapeFieldDef | undefined { - if (!table) return undefined; - const shapes = this.valueShapeFields[table]; - return shapes && Object.prototype.hasOwnProperty.call(shapes, localField) ? shapes[localField] : undefined; + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.declaredValueShape.call(filterHelpersHost.of(this), table, localField); } /** @@ -8821,11 +7352,7 @@ export class SqlDriver implements IDataDriver { */ protected logWithheldFilterDiagnostic(err: unknown): void { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). - const readLogger = () => this.logger; - const host: ThisParameterType = { - get logger() { return readLogger(); }, - }; - return logWithheldFilterDiagnostic.call(host, err); + return FilterHelpersFacet.logWithheldFilterDiagnostic.call(filterHelpersHost.of(this), err); } /** @@ -8843,43 +7370,18 @@ export class SqlDriver implements IDataDriver { * that passes two of these frames is still logged once. */ protected withWithheldFilterLog(rootFilter: unknown, fn: () => T): T { - try { - return fn(); - } catch (err) { - // [#8220] The same provenance resolution `applyFilters` applies, against - // the same `where` ROOT — threaded into this frame because a lazily - // invoked group callback is past that entry point's catch. Resolving - // against the local branch instead would lose every mark set on an - // ANCESTOR (the merge boundaries mark the arms of their own `$and`), so - // an author's nested `$or` refusal would stay redacted for no reason. - throw this.resolveWithheldFilterRefusal(err, rootFilter); - } + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return (FilterHelpersFacet.withWithheldFilterLog).call(filterHelpersHost.of(this), rootFilter, fn); } private compileFilters(builder: Knex.QueryBuilder, filters: any, options?: DriverOptions) { // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). - const host: ThisParameterType = { - applyFilterCondition: (...a) => this.applyFilterCondition(...a), - applyNormalizedComparison: (...a) => this.applyNormalizedComparison(...a), - assertOperatorAppliesToColumn: (...a) => this.assertOperatorAppliesToColumn(...a), - coerceFilterValue: (...a) => this.coerceFilterValue(...a), - coercionKey: (...a) => this.coercionKey(...a), - filterColumnExpr: (...a) => this.filterColumnExpr(...a), - remoteColumn: (...a) => this.remoteColumn(...a), - }; - return compileFilters.call(host, builder, filters, options); + return FilterHelpersFacet.compileFilters.call(filterHelpersHost.of(this), builder, filters, options); } - /** - * Apply a `contains` substring match as a parameterized `LIKE '%…%'`, escaping - * the LIKE metacharacters `%` / `_` (and the escape char `\`) in the user value - * so they match literally instead of acting as wildcards — otherwise a value of - * `%` matches every row (a filter-bypass, P0). Binds an explicit `ESCAPE '\'` - * because SQLite does not honour a default escape character (MySQL/Postgres do, - * but the explicit clause is correct for all three). - */ private applyContainsLike(builder: any, method: string, field: string, value: unknown): void { - this.applyLike(builder, method, field, value, 'contains'); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`, its docblock with it (see `FilterHelpersHost`). + return FilterHelpersFacet.applyContainsLike.call(filterHelpersHost.of(this), builder, method, field, value); } /** @@ -9540,8 +8042,8 @@ export class SqlDriver implements IDataDriver { * refuses rather than guessing at columns it cannot enumerate. */ protected declaredFieldsFor(object: string): Record | undefined { - const tableName = StorageNameMapping.resolveTableName({ name: object } as any); - return this.managedObjectFields.get(tableName) ?? this.managedObjectFields.get(object); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts` (see `FilterHelpersHost`). + return FilterHelpersFacet.declaredFieldsFor.call(filterHelpersHost.of(this), object); } /** @@ -9760,25 +8262,16 @@ export class SqlDriver implements IDataDriver { return crossFieldOffsetExpr.call(host, base, offset, cls); } - /** - * [#5134] Emit the dialect FALSE constant — a predicate that matches no row. - * - * `1 = 0` is the spelling already used for this condition on both sides of the - * repo: `read-scope-sql.ts` compiles an empty `$in` to it, and Knex itself - * renders an empty `whereIn` as `1 = 0`. It is valid on every dialect this - * driver targets (unlike a bare `FALSE`, which MySQL accepts but older SQL - * Server does not), needs no bindings, and keeps the query a normal SELECT so - * `LIMIT`/`ORDER BY`/aggregates all still behave. - */ private applyFalseConstant(builder: any, logicalOp: 'and' | 'or'): void { - builder[logicalOp === 'or' ? 'orWhereRaw' : 'whereRaw']('1 = 0'); + // [#29520 codemod] The body moved to `sql-driver-filter-helpers.ts`, its docblock with it. + return FilterHelpersFacet.applyFalseConstant(builder, logicalOp); } // ── Field mapping ─────────────────────────────────────────────────────────── protected mapSortField(field: string): string { // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts`. - return mapSortField(field); + return FieldMappingFacet.mapSortField(field); } /** @@ -9814,10 +8307,8 @@ export class SqlDriver implements IDataDriver { * like any other sort key. */ protected paginationTieBreaker(object: string): string | null { - const tableName = StorageNameMapping.resolveTableName({ name: object } as any); - const managed = - this.managedObjectFields.has(tableName) || this.managedObjectFields.has(object); - return managed ? 'id' : null; + // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts` (see `FieldMappingHost`). + return FieldMappingFacet.paginationTieBreaker.call(fieldMappingHost.of(this), object); } /** @@ -9864,14 +8355,7 @@ export class SqlDriver implements IDataDriver { opts?: { singleRowLookup?: boolean }, ): Array<{ field: string; direction: 'asc' | 'desc' }> { // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts` (see `FieldMappingHost`). - const readLogger = () => this.logger; - const readNondeterministicPagingWarned = () => this.nondeterministicPagingWarned; - const host: ThisParameterType = { - get logger() { return readLogger(); }, - get nondeterministicPagingWarned() { return readNondeterministicPagingWarned(); }, - paginationTieBreaker: (...a) => this.paginationTieBreaker(...a), - }; - return orderKeysFor.call(host, object, query, opts); + return FieldMappingFacet.orderKeysFor.call(fieldMappingHost.of(this), object, query, opts); } /** @@ -9881,8 +8365,8 @@ export class SqlDriver implements IDataDriver { * and external objects without a columnMap are byte-for-byte unchanged. */ protected remoteColumn(object: string | null | undefined, field: string, fallback: string): string { - const m = object ? this.fieldColumnByObject[object] : undefined; - return (m && m[field]) || fallback; + // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts` (see `FieldMappingHost`). + return FieldMappingFacet.remoteColumn.call(fieldMappingHost.of(this), object, field, fallback); } /** @@ -9891,11 +8375,8 @@ export class SqlDriver implements IDataDriver { * (whose value coercion is keyed by logical field name). */ protected applyWriteColumnMap(object: string, data: any): any { - const m = this.fieldColumnByObject[object]; - if (!m || !data || typeof data !== 'object') return data; - const out: any = {}; - for (const [k, v] of Object.entries(data)) out[m[k] ?? k] = v; - return out; + // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts` (see `FieldMappingHost`). + return FieldMappingFacet.applyWriteColumnMap.call(fieldMappingHost.of(this), object, data); } /** @@ -9918,7 +8399,7 @@ export class SqlDriver implements IDataDriver { */ protected mapAggregateFunc(func: string): SqlAggregateLowering { // [#29520 codemod] The body moved to `sql-driver-field-mapping.ts`. - return mapAggregateFunc(func); + return FieldMappingFacet.mapAggregateFunc(func); } // ── Window function builder ───────────────────────────────────────────────── @@ -9933,12 +8414,7 @@ export class SqlDriver implements IDataDriver { */ protected buildWindowFunction(spec: SqlWindowFunctionSpec): string { // [#29520 codemod] The body moved to `sql-driver-window-function-builder.ts` (see `WindowFunctionBuilderHost`). - const readKnex = () => this.knex; - const host: ThisParameterType = { - get knex() { return readKnex(); }, - mapSortField: (...a) => this.mapSortField(...a), - }; - return buildWindowFunction.call(host, spec); + return WindowFunctionBuilderFacet.buildWindowFunction.call(windowFunctionBuilderHost.of(this), spec); } // ── Column creation helper ────────────────────────────────────────────────── @@ -10165,20 +8641,13 @@ export class SqlDriver implements IDataDriver { * decision to an unsettled one. */ protected declaredVarcharLength(field: any): number | null { - const declared = (field as { maxLength?: unknown }).maxLength; - const n = typeof declared === 'string' ? Number(declared) : declared; - if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0) { - return SqlDriver.DEFAULT_STRING_VARCHAR_CHARS; - } - return n > SqlDriver.MAX_VARCHAR_CHARS ? null : n; + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts`. + return ColumnCreationFacet.declaredVarcharLength(field); } protected keyableTextLength(field: any): number | null { - const declared = (field as { maxLength?: unknown }).maxLength; - const n = typeof declared === 'string' ? Number(declared) : declared; - if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0) return null; - if (n > SqlDriver.MAX_KEYABLE_VARCHAR_CHARS) return null; - return n; + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts`. + return ColumnCreationFacet.keyableTextLength(field); } /** @@ -10346,12 +8815,8 @@ export class SqlDriver implements IDataDriver { * get different shadows. */ protected static hashShadowColumnFor(indexName: string): string { - // Commit cd1348802: DELEGATES rather than re-deriving. The differ has to look for - // exactly the column the sync creates, and the two halves of that question - // lived in different modules — which is how a healthy shadow-carried - // UNIQUE came to be reported as destructive drift while the shadow column - // itself was protected as driver-owned. One derivation, both readers. - return hashShadowColumnFor(indexName); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts`. + return ColumnCreationFacet.hashShadowColumnFor(indexName); } /** @@ -10447,86 +8912,14 @@ export class SqlDriver implements IDataDriver { * * Returns `true` when the shadow index now exists. */ - protected async createHashShadowUniqueIndex( + protected createHashShadowUniqueIndex( tableName: string, indexName: string, columns: string[], nullSafeColumns?: ReadonlySet, ): Promise { - if (!this.isMysql) return false; - const shadow = SqlDriver.hashShadowColumnFor(indexName); - const ref = (c: string) => `\`${c.replace(/`/g, '``')}\``; - // The DECLARED key part: NULL-safe parts in their COALESCE form (commit df1c75c4b), - // plain parts as the bare column. - const part = (c: string) => - nullSafeColumns?.has(c) ? `COALESCE(${ref(c)}, '${GLOBAL_TENANT}')` : ref(c); - // ONE argument needs no separator, and CONCAT of one value would only add - // a chance to get the encoding wrong. - const expr = - columns.length === 1 - ? part(columns[0]!) - : `CONCAT(${columns.map(part).join(', 0x1f, ')})`; - const addColumn = - `ADD COLUMN ${ref(shadow)} VARBINARY(32) GENERATED ALWAYS AS (UNHEX(SHA2(${expr}, 256))) STORED`; - const addKey = `ADD UNIQUE KEY ${ref(indexName)} (${ref(shadow)})`; - // Commit cd1348802: the shadow column OUTLIVES the index it carries. Dropping a - // UNIQUE key by name does not drop the generated column keyed by it, so - // every path that drops and re-syncs — `recreate_index` above all — arrives - // back here with the survivor still on the table. The unconditional - // `ADD COLUMN` then failed with a duplicate-COLUMN error, which is matched - // by NEITHER the "already exists" absorb (that spells index names) nor the - // unique-violation branch, so the apply ended with the constraint DROPPED - // and not re-created: an operator following the differ's own advice removed - // a live uniqueness guarantee. The survivor is therefore inspected, never - // assumed absent. - const state = await this.hashShadowColumnState(tableName, indexName, columns, nullSafeColumns); - if (state === 'foreign') { - // A real, non-generated column already owns the name. It is not ours to - // drop — it may hold data — so this route is refused and the caller falls - // through to the refusal commit d0e3a885b named, which is the honest outcome. - this.logDurabilityFailure( - `[sql-driver] cannot carry UNIQUE index '${indexName}' on "${tableName}" on a hash shadow — the ` + - `column "${shadow}" already exists and is NOT a generated column, so it is not the driver's to ` + - `replace: it may hold data. The declared constraint is NOT enforced; rename or drop that column.`, - ); - return false; - } - if (state === 'reusable') { - // The survivor already hashes exactly the declared key — re-key it rather - // than rebuild the table for a column that is byte-for-byte what we want. - await this.knex.raw(`ALTER TABLE ${ref(tableName)} ${addKey}`); - } else if (state === 'stale') { - // The survivor hashes a DIFFERENT key than metadata now declares — the - // raw-column shadow from before commit df1c75c4b is exactly this case. Reusing it would - // re-enforce the old constraint under the new name, which is the silent - // wrong answer; the column carries no user data (it is derived), so it is - // dropped and re-generated. Two statements rather than one ALTER: the - // index it carried is already gone by the time we are here, and a single - // `DROP COLUMN c, ADD COLUMN c` is a shape not worth relying on. - await this.knex.raw(`ALTER TABLE ${ref(tableName)} DROP COLUMN ${ref(shadow)}`); - await this.knex.raw(`ALTER TABLE ${ref(tableName)} ${addColumn}, ${addKey}`); - } else { - await this.knex.raw(`ALTER TABLE ${ref(tableName)} ${addColumn}, ${addKey}`); - } - // The boot log describes the key the shadow actually enforces — the - // NULL-safe parts in their COALESCE spelling — so "carried" can be read - // literally (commit df1c75c4b). - const described = columns - .map((c) => (nullSafeColumns?.has(c) ? `COALESCE(${c}, '${GLOBAL_TENANT}')` : c)) - .join(', '); - this.logger.warn( - `[sql-driver] UNIQUE index '${indexName}' on "${tableName}" is carried by the hash-shadow column ` + - `"${shadow}" (SHA-256 of ${described}), because MySQL cannot key ${columns.length > 1 ? 'this column set' : 'a column'} ` + - `longer than ${SqlDriver.MAX_KEYABLE_VARCHAR_CHARS} characters directly. The declared ` + - `constraint is enforced over the full value; only the physical key differs.` + - (state === 'reusable' - ? ` The shadow column already existed and hashes this exact key — re-keyed in place.` - : state === 'stale' - ? ` A surviving shadow column hashed a DIFFERENT key and was re-generated.` - : ''), - { tableName, indexName, columns, shadow, shadowColumnState: state }, - ); - return true; + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.createHashShadowUniqueIndex.call(columnCreationHost.of(this), tableName, indexName, columns, nullSafeColumns); } /** @@ -10551,37 +8944,14 @@ export class SqlDriver implements IDataDriver { * shipped before this probe existed — rather than converting a transient * read failure into a refused constraint. */ - protected async hashShadowColumnState( + protected hashShadowColumnState( tableName: string, indexName: string, columns: string[], nullSafeColumns?: ReadonlySet, ): Promise<'absent' | 'reusable' | 'stale' | 'foreign'> { - let rows: Array>; - try { - rows = await this.knex - .select('GENERATION_EXPRESSION') - .from('information_schema.COLUMNS') - .where({ - TABLE_SCHEMA: this.knex.client.database(), - TABLE_NAME: tableName, - COLUMN_NAME: SqlDriver.hashShadowColumnFor(indexName), - }); - } catch { - return 'absent'; - } - if (rows.length === 0) return 'absent'; - const expr = String(rows[0]?.GENERATION_EXPRESSION ?? rows[0]?.generation_expression ?? ''); - if (expr.trim() === '') return 'foreign'; - const parts = parseHashShadowKeyParts(expr); - if (parts.length === 0) return 'stale'; - const carried = canonicalIndexKey( - parts.map((k) => k.column), - parts.filter((k) => k.nullSafe).map((k) => k.column), - ); - return carried === canonicalIndexKey(columns, [...(nullSafeColumns ?? [])]) - ? 'reusable' - : 'stale'; + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.hashShadowColumnState.call(columnCreationHost.of(this), tableName, indexName, columns, nullSafeColumns); } /** @@ -10623,77 +8993,12 @@ export class SqlDriver implements IDataDriver { * * Returns `true` only when a column was actually dropped. */ - protected async dropOrphanedHashShadowColumn( + protected dropOrphanedHashShadowColumn( tableName: string, indexName: string, ): Promise { - // Shadows are a MySQL-only construct: `createHashShadowUniqueIndex` refuses - // every other dialect, so there is nothing to collect and no catalog read - // worth paying for. - if (!this.isMysql) return false; - const shadow = SqlDriver.hashShadowColumnFor(indexName); - - let generationExpression: string; - try { - const rows = await this.knex - .select('GENERATION_EXPRESSION') - .from('information_schema.COLUMNS') - .where({ - TABLE_SCHEMA: this.knex.client.database(), - TABLE_NAME: tableName, - COLUMN_NAME: shadow, - }); - if (rows.length === 0) return false; // nothing survived; the ordinary case - generationExpression = String( - rows[0]?.GENERATION_EXPRESSION ?? rows[0]?.generation_expression ?? '', - ); - } catch { - return false; - } - if (generationExpression.trim() === '') { - this.logger.warn( - `[sql-driver] NOT dropping "${tableName}"."${shadow}" while collecting the retired index ` + - `'${indexName}' — a column of that name exists and is NOT a generated column, so it is not ` + - `the driver's to drop and may hold data. Remove it by hand if it is unwanted.`, - { tableName, indexName, shadow }, - ); - return false; - } - - let stillKeyedBy: string[]; - try { - const rows = await this.knex - .select('INDEX_NAME') - .from('information_schema.STATISTICS') - .where({ - TABLE_SCHEMA: this.knex.client.database(), - TABLE_NAME: tableName, - COLUMN_NAME: shadow, - }); - stillKeyedBy = [ - ...new Set(rows.map((r: any) => String(r.INDEX_NAME ?? r.index_name ?? ''))), - ].filter((n) => n !== ''); - } catch { - return false; - } - if (stillKeyedBy.length > 0) { - this.logger.warn( - `[sql-driver] NOT dropping "${tableName}"."${shadow}" while collecting the retired index ` + - `'${indexName}' — ${stillKeyedBy.length} index(es) still key that column ` + - `(${stillKeyedBy.join(', ')}), so dropping it would remove them too.`, - { tableName, indexName, shadow, stillKeyedBy }, - ); - return false; - } - - await this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [tableName, shadow]); - this.logger.warn( - `[sql-driver] dropped the hash-shadow column "${shadow}" from "${tableName}" — the UNIQUE index ` + - `'${indexName}' it carried is retired, so the STORED generated column was recomputed on every ` + - `write for a constraint that no longer exists.`, - { tableName, indexName, shadow }, - ); - return true; + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.dropOrphanedHashShadowColumn.call(columnCreationHost.of(this), tableName, indexName); } /** @@ -10818,11 +9123,7 @@ export class SqlDriver implements IDataDriver { error: unknown, ): Promise { // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). - const host: ThisParameterType = { - explainHashShadowDuplicate: (...a) => this.explainHashShadowDuplicate(...a), - logDurabilityFailure: (...a) => this.logDurabilityFailure(...a), - }; - return decorateHashShadowDuplicate.call(host, tableName, values, error); + return ColumnCreationFacet.decorateHashShadowDuplicate.call(columnCreationHost.of(this), tableName, values, error); } /** @@ -10833,31 +9134,12 @@ export class SqlDriver implements IDataDriver { * `COALESCE(col, …)` form (ADR-0120 D3 via commit df1c75c4b) — the read side must * compare through the same fold the enforced key applies. */ - protected async hashShadowSourceColumns( + protected hashShadowSourceColumns( tableName: string, indexName: string, ): Promise { - try { - const rows: Array<{ GENERATION_EXPRESSION?: string; generation_expression?: string }> = - await this.knex - .select('GENERATION_EXPRESSION') - .from('information_schema.COLUMNS') - .where({ - TABLE_SCHEMA: this.knex.client.database(), - TABLE_NAME: tableName, - COLUMN_NAME: SqlDriver.hashShadowColumnFor(indexName), - }); - const expr = String(rows[0]?.GENERATION_EXPRESSION ?? rows[0]?.generation_expression ?? ''); - // `unhex(sha2(`a`,256))`, `unhex(sha2(concat(`a`,0x1f,`b`),256))`, or with - // a NULL-safe part: `…concat(coalesce(`org`,_utf8mb4'__global__'),0x1f,`b`)…` - // (commit df1c75c4b). Commit cd1348802 moved the parse itself next to the shadow vocabulary - // in `schema-drift.ts`: the differ reads the same expression to decide - // whether a shadow-carried index enforces what metadata declares, and two - // copies of this regex would be two answers to one question. - return parseHashShadowKeyParts(expr); - } catch { - return []; - } + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.hashShadowSourceColumns.call(columnCreationHost.of(this), tableName, indexName); } /** @@ -10908,8 +9190,8 @@ export class SqlDriver implements IDataDriver { * Off by one byte there would move a number in a message, never a verdict. */ protected static varcharPackLength(chars: number, bytesPerChar: number): number { - const payload = chars * bytesPerChar; - return payload + (payload < 256 ? 1 : 2); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts`. + return ColumnCreationFacet.varcharPackLength(chars, bytesPerChar); } /** @@ -11018,16 +9300,8 @@ export class SqlDriver implements IDataDriver { keyedColumns: ReadonlyMap, bytesPerChar: number, ): { columns: RowWidthContribution[]; totalBytes: number } { - const builtin = new Set(['id', ...AUDIT_TIMESTAMP_COLUMNS]); - const columns: RowWidthContribution[] = []; - for (const [name, field] of Object.entries(fields ?? {})) { - if (builtin.has(name)) continue; - const chars = this.varcharColumnChars(field, keyedColumns.get(name)); - if (chars === null) continue; - columns.push({ name, chars, bytes: SqlDriver.varcharPackLength(chars, bytesPerChar) }); - } - columns.sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name)); - return { columns, totalBytes: columns.reduce((sum, c) => sum + c.bytes, 0) }; + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.rowWidthProfile.call(columnCreationHost.of(this), fields, keyedColumns, bytesPerChar); } /** @@ -11048,11 +9322,7 @@ export class SqlDriver implements IDataDriver { */ protected schemaBytesPerChar(): Promise<{ bytesPerChar: number; charset: string } | null> { // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). - const readKnex = () => this.knex; - const host: ThisParameterType = { - get knex() { return readKnex(); }, - }; - return schemaBytesPerChar.call(host); + return ColumnCreationFacet.schemaBytesPerChar.call(columnCreationHost.of(this)); } /** @@ -11113,76 +9383,17 @@ export class SqlDriver implements IDataDriver { * years later is refused by the width of columns nobody is touching — so * the offender list is the same and only the opening clause differs. */ - protected async explainRowSizeOverflow( + protected explainRowSizeOverflow( tableName: string, fields: Record | undefined, keyedColumns: ReadonlyMap, addedColumns: string[] | null, cause: unknown, ): Promise { - if ((cause as { code?: string } | undefined)?.code !== 'ER_TOO_BIG_ROWSIZE') return null; - - const server = await this.schemaBytesPerChar(); - const bytesPerChar = server?.bytesPerChar ?? SqlDriver.ASSUMED_BYTES_PER_CHAR; - const charsetNote = server - ? `${bytesPerChar} bytes/character (${server.charset}, this schema's charset)` - : `${bytesPerChar} bytes/character (utf8mb4 assumed — the server's charset could not be read)`; - const { columns, totalBytes } = this.rowWidthProfile(fields, keyedColumns, bytesPerChar); - - // Two DIFFERENT limits answer with this one code, and they quote different - // numbers. Reading the server's own text keeps the message from asserting - // the 65535 one over a refusal that was really InnoDB's page limit. - const serverText = String((cause as { sqlMessage?: string; message?: string }).sqlMessage ?? ''); - const pageLimit = /row size too large \(>\s*(\d+)\)/i.exec(serverText)?.[1] ?? null; - const limitClause = pageLimit - ? `InnoDB's per-PAGE limit of ${pageLimit} bytes — which many NARROW bounded columns reach far below the ` + - `${SqlDriver.MYSQL_ROW_BYTE_BUDGET}-byte one, because a column narrower than about 256 bytes is never ` + - `stored off-page (measured on 8.0.46: 40 fields at \`maxLength: 63\` are refused)` - : `the server's ${SqlDriver.MYSQL_ROW_BYTE_BUDGET}-byte budget for one ROW`; - - const SHOWN = 8; - const listed = columns - .slice(0, SHOWN) - .map((c) => `"${c.name}" varchar(${c.chars}) = ${c.bytes} bytes`) - .join(', '); - const rest = columns.length > SHOWN ? `, and ${columns.length - SHOWN} more` : ''; - const inventory = - columns.length === 0 - ? `This object produces no varchar column at all, so the width is in the built-in columns or in a ` + - `type this diagnostic does not size — report it, because that is not a shape it was measured on.` - : `Its ${columns.length} varchar column(s) take ${totalBytes} bytes at ${charsetNote}, on top of the ` + - `built-in \`id\` (a varchar(${SqlDriver.DEFAULT_STRING_VARCHAR_CHARS})), \`created_at\` and ` + - `\`updated_at\` columns. Widest first: ${listed}${rest}.`; - - const opening = - addedColumns && addedColumns.length > 0 - ? `[sql-driver] cannot add column(s) ${addedColumns.map((c) => `"${c}"`).join(', ')} to "${tableName}"` - : `[sql-driver] cannot create table "${tableName}"`; - - return ( - `${opening} — MySQL refuses the whole row: every bounded column's DECLARED width is charged against ` + - `${limitClause}, whether or not a row ever holds that much. ${inventory} ` + - `Fix it on the declarations: lower \`maxLength\` on the widest field(s), or drop the bound from a ` + - `text-family field no index keys on so it is emitted as TEXT — an off-page TEXT column costs this ` + - `budget a small pointer rather than its full declared width. Note that a field declaring NO ` + - `\`maxLength\` still takes varchar(${SqlDriver.DEFAULT_STRING_VARCHAR_CHARS}) ` + - `(${SqlDriver.varcharPackLength(SqlDriver.DEFAULT_STRING_VARCHAR_CHARS, bytesPerChar)} bytes here), and ` + - `so do \`lookup\`, \`user\`, \`auto_number\` and the option types — an object can reach this limit ` + - `without declaring a bound anywhere. The server's own error names no column, which is the whole reason ` + - `this one does.` - ); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.explainRowSizeOverflow.call(columnCreationHost.of(this), tableName, fields, keyedColumns, addedColumns, cause); } - /** - * Re-throw a schema-mutation failure carrying {@link explainRowSizeOverflow}'s - * explanation, or the original untouched when it is not a row-size refusal. - * - * The SAME failure, re-worded — the boot still fails, loudly, and no object is - * registered that MySQL declined to build. `sqlMessage` rather than `message` - * for the quoted server text: knex prefixes `message` with the entire DDL - * statement, which for the object that trips this is thousands of characters - * of `varchar(...)` and buries the one sentence the server actually said. - */ private rethrowWithRowSizeExplanation( tableName: string, fields: Record | undefined, @@ -11190,12 +9401,8 @@ export class SqlDriver implements IDataDriver { addedColumns: string[] | null, e: any, ): Promise { - // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). - const host: ThisParameterType = { - explainRowSizeOverflow: (...a) => this.explainRowSizeOverflow(...a), - logDurabilityFailure: (...a) => this.logDurabilityFailure(...a), - }; - return rethrowWithRowSizeExplanation.call(host, tableName, fields, keyedColumns, addedColumns, e); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts`, its docblock with it (see `ColumnCreationHost`). + return ColumnCreationFacet.rethrowWithRowSizeExplanation.call(columnCreationHost.of(this), tableName, fields, keyedColumns, addedColumns, e); } /** @@ -11266,7 +9473,8 @@ export class SqlDriver implements IDataDriver { * this change per its own header (it now pins the convergence). */ protected jsonColumn(table: Knex.CreateTableBuilder, name: string): any { - return this.isSqlite ? table.text(name) : table.json(name); + // [#29520 codemod] The body moved to `sql-driver-column-creation.ts` (see `ColumnCreationHost`). + return ColumnCreationFacet.jsonColumn.call(columnCreationHost.of(this), table, name); } protected createColumn( @@ -11836,15 +10044,7 @@ export class SqlDriver implements IDataDriver { */ protected sqliteFilename(): string | null { // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). - const readConfig = () => this.config; - const readIsSqlite = () => this.isSqlite; - const readOpenedEmptyInMemory = () => this.openedEmptyInMemory; - const host: ThisParameterType = { - get config() { return readConfig(); }, - get isSqlite() { return readIsSqlite(); }, - get openedEmptyInMemory() { return readOpenedEmptyInMemory(); }, - }; - return sqliteFilename.call(host); + return DatabaseHelpersFacet.sqliteFilename.call(databaseHelpersHost.of(this)); } protected async ensureDatabaseExists() { @@ -11988,10 +10188,8 @@ export class SqlDriver implements IDataDriver { * installation from a refusal instead of inferring it. */ setFileColumnsMovedResolver(resolve: () => boolean | Promise): boolean { - if (this.fileColumnsMovedDeclared) return false; - if (this.fileColumnsMovedAsked) return false; - this.fileColumnsMovedResolver = resolve; - return true; + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + return DatabaseHelpersFacet.setFileColumnsMovedResolver.call(databaseHelpersHost.of(this), resolve); } /** Has {@link resolveFileColumnsMoved} already run to completion? */ @@ -12017,8 +10215,8 @@ export class SqlDriver implements IDataDriver { * ownership ceremony as the remedy. */ setTenantKeyCertifiedResolver(resolve: () => boolean | Promise): void { - this.tenantKeyCertifiedResolver = resolve; - this.tenantKeyCertified = undefined; + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + return DatabaseHelpersFacet.setTenantKeyCertifiedResolver.call(databaseHelpersHost.of(this), resolve); } private tenantKeyCertifiedResolver?: () => boolean | Promise; @@ -12032,55 +10230,14 @@ export class SqlDriver implements IDataDriver { * it. A yes is kept; a no is asked again next time, so an answer taken * before the ledger could be read never outlives that moment. */ - protected async isTenantKeyCertified(): Promise { - if (this.tenantKeyCertified) return true; - const resolver = this.tenantKeyCertifiedResolver; - if (!resolver) return false; - try { - // Asked inside the pre-DDL scope: a store whose ledger does not exist - // yet answers with a refused read, which is not a fault (see - // {@link PRE_DDL_QUESTION_SCOPE}). - if ((await PRE_DDL_QUESTION_SCOPE.run(true, resolver)) !== true) return false; - } catch (e: any) { - // Not knowing reads as "not certified": the forms stay distinct and an - // 18.0 index is reported as a destructive recreate_index, which nothing - // applies unattended (safe-only boot reconcile, the artifact gate, - // `--allow-destructive`). Said once, so that report is not taken for a - // verdict on a certified store. - if (!this.tenantKeyCertifiedReadFailureReported) { - this.tenantKeyCertifiedReadFailureReported = true; - this.logger.warn( - `[schema-drift] could not read whether this database is certified for the organization-ownership shape ` + - `(${e?.message ?? e}); an index keyed COALESCE(, …) on a nullable tenant column is compared ` + - `as a different constraint from the plain key until it can be read.`, - ); - } - return false; - } - this.tenantKeyCertified = true; - return true; + protected isTenantKeyCertified(): Promise { + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + return DatabaseHelpersFacet.isTenantKeyCertified.call(databaseHelpersHost.of(this)); } - protected async resolveFileColumnsMoved(): Promise { - const resolver = this.fileColumnsMovedResolver; - if (!resolver) { - // A driver with nothing to ask has still settled its arm: `false`, the - // JSON encoding, which is what it will keep for the rest of its life. - // Recorded so a resolver supplied AFTER the first `initObjects` is - // refused rather than silently changing an already-frozen answer. - this.fileColumnsMovedAsked = true; - return; - } - this.fileColumnsMovedAsked = true; - this.fileColumnsMovedResolver = undefined; - try { - // [#20768] Asked inside the pre-DDL scope. On a new database the ledger - // the resolver reads does not exist yet, and that refusal is not a - // fault. The answer is unchanged — see {@link PRE_DDL_QUESTION_SCOPE}. - this.fileColumnsMoved = (await PRE_DDL_QUESTION_SCOPE.run(true, resolver)) === true; - } catch { - this.fileColumnsMoved = false; - } + protected resolveFileColumnsMoved(): Promise { + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + return DatabaseHelpersFacet.resolveFileColumnsMoved.call(databaseHelpersHost.of(this)); } /** @@ -12092,7 +10249,8 @@ export class SqlDriver implements IDataDriver { * this question — it is a list of ids and a JSON column on every deployment. */ protected mediaColumnIsJson(): boolean { - return !this.fileColumnsMoved; + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + return DatabaseHelpersFacet.mediaColumnIsJson.call(databaseHelpersHost.of(this)); } /** @@ -12117,66 +10275,20 @@ export class SqlDriver implements IDataDriver { * and keep their column unchanged. */ protected isJsonField(type: string, field: any): boolean { - if (!isMultiValuedColumn(type, field) && FILE_REFERENCE_TYPES.has(type)) return this.mediaColumnIsJson(); - return JSON_COLUMN_TYPES.has(type) || isMultiValuedColumn(type, field); + // [#29520 codemod] The body moved to `sql-driver-database-helpers.ts` (see `DatabaseHelpersHost`). + return DatabaseHelpersFacet.isJsonField.call(databaseHelpersHost.of(this), type, field); } // ── SQLite serialisation ──────────────────────────────────────────────────── protected formatInput(object: string, data: any): any { // [#29520 codemod] The body moved to `sql-driver-sqlite-serialisation.ts` (see `SqliteSerialisationHost`). - const readDateFields = () => this.dateFields; - const readDatetimeFields = () => this.datetimeFields; - const readFileColumnsMoved = () => this.fileColumnsMoved; - const readIsSqlite = () => this.isSqlite; - const readJsonFields = () => this.jsonFields; - const readMediaFields = () => this.mediaFields; - const readTimeFields = () => this.timeFields; - const readUndeclaredAuditTimestampFields = () => this.undeclaredAuditTimestampFields; - const host: ThisParameterType = { - get dateFields() { return readDateFields(); }, - get datetimeFields() { return readDatetimeFields(); }, - get fileColumnsMoved() { return readFileColumnsMoved(); }, - get isSqlite() { return readIsSqlite(); }, - get jsonFields() { return readJsonFields(); }, - get mediaFields() { return readMediaFields(); }, - storageDatetimeValue: (...a) => this.storageDatetimeValue(...a), - get timeFields() { return readTimeFields(); }, - toDateOnly: (...a) => this.toDateOnly(...a), - get undeclaredAuditTimestampFields() { return readUndeclaredAuditTimestampFields(); }, - }; - return formatInput.call(host, object, data); + return SqliteSerialisationFacet.formatInput.call(sqliteSerialisationHost.of(this), object, data); } protected formatOutput(object: string, data: any): any { // [#29520 codemod] The body moved to `sql-driver-sqlite-serialisation.ts` (see `SqliteSerialisationHost`). - const readBooleanFields = () => this.booleanFields; - const readColumnFieldByObject = () => this.columnFieldByObject; - const readDateFields = () => this.dateFields; - const readDatetimeFields = () => this.datetimeFields; - const readIsMysql = () => this.isMysql; - const readIsSqlite = () => this.isSqlite; - const readJsonFields = () => this.jsonFields; - const readMediaFields = () => this.mediaFields; - const readNumericFields = () => this.numericFields; - const readNumericValueFields = () => this.numericValueFields; - const readTimeFields = () => this.timeFields; - const host: ThisParameterType = { - get booleanFields() { return readBooleanFields(); }, - get columnFieldByObject() { return readColumnFieldByObject(); }, - get dateFields() { return readDateFields(); }, - get datetimeFields() { return readDatetimeFields(); }, - get isMysql() { return readIsMysql(); }, - get isSqlite() { return readIsSqlite(); }, - get jsonFields() { return readJsonFields(); }, - get mediaFields() { return readMediaFields(); }, - get numericFields() { return readNumericFields(); }, - get numericValueFields() { return readNumericValueFields(); }, - get timeFields() { return readTimeFields(); }, - toDateOnly: (...a) => this.toDateOnly(...a), - toTimeOnly: (...a) => this.toTimeOnly(...a), - }; - return formatOutput.call(host, object, data); + return SqliteSerialisationFacet.formatOutput.call(sqliteSerialisationHost.of(this), object, data); } // ── Introspection internals ───────────────────────────────────────────────── @@ -12382,6 +10494,413 @@ export class SqlDriver implements IDataDriver { }; return introspectUniqueConstraints.call(host, tableName, opts); } + + // [#29520 codemod] Fills `SqlDriverStatics` (declared above the class) for the facet modules. + static { + Object.defineProperties(SqlDriverStatics, { + ASSUMED_BYTES_PER_CHAR: { get: () => SqlDriver.ASSUMED_BYTES_PER_CHAR }, + DEFAULT_CONNECT_TIMEOUT_MS: { get: () => SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS }, + DEFAULT_CREATE_TIMEOUT_MS: { get: () => SqlDriver.DEFAULT_CREATE_TIMEOUT_MS }, + DEFAULT_STRING_VARCHAR_CHARS: { get: () => SqlDriver.DEFAULT_STRING_VARCHAR_CHARS }, + DIALECT_CONNECT_TIMEOUT: { get: () => SqlDriver.DIALECT_CONNECT_TIMEOUT }, + MAX_KEYABLE_VARCHAR_CHARS: { get: () => SqlDriver.MAX_KEYABLE_VARCHAR_CHARS }, + MAX_VARCHAR_CHARS: { get: () => SqlDriver.MAX_VARCHAR_CHARS }, + MYSQL_EMIT_CLIENTS: { get: () => SqlDriver.MYSQL_EMIT_CLIENTS }, + MYSQL_ROW_BYTE_BUDGET: { get: () => SqlDriver.MYSQL_ROW_BYTE_BUDGET }, + MYSQL_TEXT_TEMPORAL_TYPES: { get: () => SqlDriver.MYSQL_TEXT_TEMPORAL_TYPES }, + PG_OID_DATE: { get: () => SqlDriver.PG_OID_DATE }, + PG_OID_DATE_ARRAY: { get: () => SqlDriver.PG_OID_DATE_ARRAY }, + PG_OID_TEXT_ARRAY: { get: () => SqlDriver.PG_OID_TEXT_ARRAY }, + POSTGRES_EMIT_CLIENTS: { get: () => SqlDriver.POSTGRES_EMIT_CLIENTS }, + POSTGRES_WIRE_CLIENTS: { get: () => SqlDriver.POSTGRES_WIRE_CLIENTS }, + POSTGRES_WIRE_ONLY_CLIENTS: { get: () => SqlDriver.POSTGRES_WIRE_ONLY_CLIENTS }, + SQLITE_AUTO_VACUUM_INCREMENTAL: { get: () => SqlDriver.SQLITE_AUTO_VACUUM_INCREMENTAL }, + SQLITE_EMIT_CLIENTS: { get: () => SqlDriver.SQLITE_EMIT_CLIENTS }, + SQLITE_FILE_REFUSALS: { get: () => SqlDriver.SQLITE_FILE_REFUSALS }, + SQLITE_JOURNAL_MODE_ENV: { get: () => SqlDriver.SQLITE_JOURNAL_MODE_ENV }, + autoVacuumOf: { value: (...args: Parameters) => SqlDriver.autoVacuumOf(...args) }, + clientSpelling: { value: (...args: Parameters) => SqlDriver.clientSpelling(...args) }, + emissionClients: { value: (...args: Parameters) => SqlDriver.emissionClients(...args) }, + hashShadowColumnFor: { value: (...args: Parameters) => SqlDriver.hashShadowColumnFor(...args) }, + isSqliteBusyError: { value: (...args: Parameters) => SqlDriver.isSqliteBusyError(...args) }, + journalModeOf: { value: (...args: Parameters) => SqlDriver.journalModeOf(...args) }, + varcharPackLength: { value: (...args: Parameters) => SqlDriver.varcharPackLength(...args) }, + withMysqlCalendarDayAsText: { value: (...args: Parameters) => SqlDriver.withMysqlCalendarDayAsText(...args) }, + withPostgresCalendarDayAsText: { value: (...args: Parameters) => SqlDriver.withPostgresCalendarDayAsText(...args) }, + withUtcSession: { value: (...args: Parameters) => SqlDriver.withUtcSession(...args) }, + }); + } + + // [#29520 codemod] Installs each facet's host factory (`sql-driver-facet-hosts.ts`): a static block adds + // no member to the class, so the published `.d.ts` does not move. + static { + aggregationHost.install((driver) => ({ + aggregandColumnClassOf: (...a) => driver.aggregandColumnClassOf(...a), + aggregateBackendFault: (...a) => driver.aggregateBackendFault(...a), + aliasIdentifierSql: (...a) => driver.aliasIdentifierSql(...a), + applyFilters: (...a) => driver.applyFilters(...a), + applyTenantScope: (...a) => driver.applyTenantScope(...a), + get booleanFields() { return driver.booleanFields; }, + buildDateBucketExpr: (...a) => driver.buildDateBucketExpr(...a), + coercionKey: (...a) => driver.coercionKey(...a), + get config() { return driver.config; }, + get dateGranularityCapabilities() { return driver.dateGranularityCapabilities; }, + get dialectName() { return driver.dialectName; }, + foldEmptyAggregateAnswers: (...a) => driver.foldEmptyAggregateAnswers(...a), + get fractionalNumericFields() { return driver.fractionalNumericFields; }, + get getBuilder() { return driver.getBuilder.bind(driver); }, + get knex() { return driver.knex; }, + mapAggregateFunc: (...a) => driver.mapAggregateFunc(...a), + get numericFields() { return driver.numericFields; }, + presentReadColumns: (...a) => driver.presentReadColumns(...a), + readPresentationKind: (...a) => driver.readPresentationKind(...a), + })); + bulkOperationsHost.install((driver) => ({ + applyFilters: (...a) => driver.applyFilters(...a), + applyTenantScope: (...a) => driver.applyTenantScope(...a), + applyWriteColumnMap: (...a) => driver.applyWriteColumnMap(...a), + auditMissingTenant: (...a) => driver.auditMissingTenant(...a), + autoNumberCounterKey: (...a) => driver.autoNumberCounterKey(...a), + backendStatementFault: (...a) => driver.backendStatementFault(...a), + collidingAutoNumberReservations: (...a) => driver.collidingAutoNumberReservations(...a), + fillAutoNumberFields: (...a) => driver.fillAutoNumberFields(...a), + formatInput: (...a) => driver.formatInput(...a), + formatOutput: (...a) => driver.formatOutput(...a), + get getBuilder() { return driver.getBuilder.bind(driver); }, + injectTenantOnInsert: (...a) => driver.injectTenantOnInsert(...a), + get insertReturnsStoredRows() { return driver.insertReturnsStoredRows; }, + keepSuppliedUpdatedAt: (...a) => driver.keepSuppliedUpdatedAt(...a), + get knex() { return driver.knex; }, + readBackInsertedRows: (...a) => driver.readBackInsertedRows(...a), + resyncSequenceToDataMax: (...a) => driver.resyncSequenceToDataMax(...a), + rotationShardsOf: (...a) => driver.rotationShardsOf(...a), + rotationWriteTarget: (...a) => driver.rotationWriteTarget(...a), + stampInsertTimestamps: (...a) => driver.stampInsertTimestamps(...a), + stampsUpdatedAt: (...a) => driver.stampsUpdatedAt(...a), + unresolvableFilterColumnRefusal: (...a) => driver.unresolvableFilterColumnRefusal(...a), + update: (...a) => driver.update(...a), + updateWithPresumedTimestamp: (...a) => driver.updateWithPresumedTimestamp(...a), + updatedAtStamp: (...a) => driver.updatedAtStamp(...a), + updatedAtStampIsPresumed: (...a) => driver.updatedAtStampIsPresumed(...a), + })); + classHeadHost.install((driver) => ({ + assertDialectEmits: (...a) => driver.assertDialectEmits(...a), + buildDateBucketExpr: (...a) => driver.buildDateBucketExpr(...a), + get config() { return driver.config; }, + get isSqlite() { return driver.isSqlite; }, + get knex() { return driver.knex; }, + get openedEmptyInMemory() { return driver.openedEmptyInMemory; }, + set openedEmptyInMemory(v) { driver.openedEmptyInMemory = v; }, + get schemaMode() { return driver.schemaMode; }, + })); + columnCreationHost.install((driver) => ({ + explainHashShadowDuplicate: (...a) => driver.explainHashShadowDuplicate(...a), + explainRowSizeOverflow: (...a) => driver.explainRowSizeOverflow(...a), + hashShadowColumnState: (...a) => driver.hashShadowColumnState(...a), + get isMysql() { return driver.isMysql; }, + get isSqlite() { return driver.isSqlite; }, + get knex() { return driver.knex; }, + logDurabilityFailure: (...a) => driver.logDurabilityFailure(...a), + get logger() { return driver.logger; }, + rowWidthProfile: (...a) => driver.rowWidthProfile(...a), + schemaBytesPerChar: (...a) => driver.schemaBytesPerChar(...a), + varcharColumnChars: (...a) => driver.varcharColumnChars(...a), + })); + crudHost.install((driver) => ({ + applyFilters: (...a) => driver.applyFilters(...a), + applyTenantScope: (...a) => driver.applyTenantScope(...a), + applyWriteColumnMap: (...a) => driver.applyWriteColumnMap(...a), + assertBareKnexSafe: (...a) => driver.assertBareKnexSafe(...a), + auditMissingTenant: (...a) => driver.auditMissingTenant(...a), + backendStatementFault: (...a) => driver.backendStatementFault(...a), + collidingAutoNumberReservations: (...a) => driver.collidingAutoNumberReservations(...a), + createSequencesTable: (...a) => driver.createSequencesTable(...a), + decorateHashShadowDuplicate: (...a) => driver.decorateHashShadowDuplicate(...a), + ensureSequencesKeyHashShape: (...a) => driver.ensureSequencesKeyHashShape(...a), + fillAutoNumberFields: (...a) => driver.fillAutoNumberFields(...a), + findRows: (...a) => driver.findRows(...a), + formatInput: (...a) => driver.formatInput(...a), + formatOutput: (...a) => driver.formatOutput(...a), + get getBuilder() { return driver.getBuilder.bind(driver); }, + injectTenantOnInsert: (...a) => driver.injectTenantOnInsert(...a), + get insertReturnsStoredRows() { return driver.insertReturnsStoredRows; }, + get isSqlite() { return driver.isSqlite; }, + get knex() { return driver.knex; }, + mapSortField: (...a) => driver.mapSortField(...a), + orderKeysFor: (...a) => driver.orderKeysFor(...a), + readBackInsertedRows: (...a) => driver.readBackInsertedRows(...a), + remoteColumn: (...a) => driver.remoteColumn(...a), + resolveTenantField: (...a) => driver.resolveTenantField(...a), + resyncSequenceToDataMax: (...a) => driver.resyncSequenceToDataMax(...a), + rotationWriteTarget: (...a) => driver.rotationWriteTarget(...a), + get sequencesHasKeyHash() { return driver.sequencesHasKeyHash; }, + set sequencesHasKeyHash(v) { driver.sequencesHasKeyHash = v; }, + get sequencesTableEnsurePromise() { return driver.sequencesTableEnsurePromise; }, + set sequencesTableEnsurePromise(v) { driver.sequencesTableEnsurePromise = v; }, + get sequencesTableReady() { return driver.sequencesTableReady; }, + set sequencesTableReady(v) { driver.sequencesTableReady = v; }, + stampInsertTimestamps: (...a) => driver.stampInsertTimestamps(...a), + unresolvableFilterColumnRefusal: (...a) => driver.unresolvableFilterColumnRefusal(...a), + })); + databaseHelpersHost.install((driver) => ({ + get config() { return driver.config; }, + get fileColumnsMoved() { return driver.fileColumnsMoved; }, + set fileColumnsMoved(v) { driver.fileColumnsMoved = v; }, + get fileColumnsMovedAsked() { return driver.fileColumnsMovedAsked; }, + set fileColumnsMovedAsked(v) { driver.fileColumnsMovedAsked = v; }, + get fileColumnsMovedDeclared() { return driver.fileColumnsMovedDeclared; }, + get fileColumnsMovedResolver() { return driver.fileColumnsMovedResolver; }, + set fileColumnsMovedResolver(v) { driver.fileColumnsMovedResolver = v; }, + get isSqlite() { return driver.isSqlite; }, + get logger() { return driver.logger; }, + mediaColumnIsJson: (...a) => driver.mediaColumnIsJson(...a), + get openedEmptyInMemory() { return driver.openedEmptyInMemory; }, + get tenantKeyCertified() { return driver.tenantKeyCertified; }, + set tenantKeyCertified(v) { driver.tenantKeyCertified = v; }, + get tenantKeyCertifiedReadFailureReported() { return driver.tenantKeyCertifiedReadFailureReported; }, + set tenantKeyCertifiedReadFailureReported(v) { driver.tenantKeyCertifiedReadFailureReported = v; }, + get tenantKeyCertifiedResolver() { return driver.tenantKeyCertifiedResolver; }, + set tenantKeyCertifiedResolver(v) { driver.tenantKeyCertifiedResolver = v; }, + })); + deferredDdlHost.install((driver) => ({ + get deferredDdl() { return driver.deferredDdl; }, + set deferredDdl(v) { driver.deferredDdl = v; }, + get deferredSchemaObjects() { return driver.deferredSchemaObjects; }, + get flushingDeferredDdl() { return driver.flushingDeferredDdl; }, + set flushingDeferredDdl(v) { driver.flushingDeferredDdl = v; }, + initObjects: (...a) => driver.initObjects(...a), + previewDeferredSchemaWork: (...a) => driver.previewDeferredSchemaWork(...a), + get tablesCreatedHere() { return driver.tablesCreatedHere; }, + get tablesFoundExisting() { return driver.tablesFoundExisting; }, + })); + distinctHost.install((driver) => ({ + applyFilters: (...a) => driver.applyFilters(...a), + applyTenantScope: (...a) => driver.applyTenantScope(...a), + coercionKey: (...a) => driver.coercionKey(...a), + distinctBackendFault: (...a) => driver.distinctBackendFault(...a), + get getBuilder() { return driver.getBuilder.bind(driver); }, + presentReadValue: (...a) => driver.presentReadValue(...a), + readPresentationKind: (...a) => driver.readPresentationKind(...a), + })); + fieldMappingHost.install((driver) => ({ + get fieldColumnByObject() { return driver.fieldColumnByObject; }, + get logger() { return driver.logger; }, + get managedObjectFields() { return driver.managedObjectFields; }, + get nondeterministicPagingWarned() { return driver.nondeterministicPagingWarned; }, + paginationTieBreaker: (...a) => driver.paginationTieBreaker(...a), + })); + filterHelpersHost.install((driver) => ({ + applyFalseConstant: (...a) => driver.applyFalseConstant(...a), + applyFilterCondition: (...a) => driver.applyFilterCondition(...a), + applyLike: (...a) => driver.applyLike(...a), + applyNormalizedComparison: (...a) => driver.applyNormalizedComparison(...a), + assertOperatorAppliesToColumn: (...a) => driver.assertOperatorAppliesToColumn(...a), + get booleanFields() { return driver.booleanFields; }, + get canonicalDatetimeFields() { return driver.canonicalDatetimeFields; }, + get canonicalTimeFields() { return driver.canonicalTimeFields; }, + coerceFilterValue: (...a) => driver.coerceFilterValue(...a), + coercionKey: (...a) => driver.coercionKey(...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; }, + get jsonFields() { return driver.jsonFields; }, + get logger() { return driver.logger; }, + get managedObjectFields() { return driver.managedObjectFields; }, + get mediaFields() { return driver.mediaFields; }, + needsLegacyDatetimeRepair: (...a) => driver.needsLegacyDatetimeRepair(...a), + needsLegacyTimeRepair: (...a) => driver.needsLegacyTimeRepair(...a), + get numericFields() { return driver.numericFields; }, + get objectByPhysicalTable() { return driver.objectByPhysicalTable; }, + presentReadValue: (...a) => driver.presentReadValue(...a), + remoteColumn: (...a) => driver.remoteColumn(...a), + resolveWithheldFilterRefusal: (...a) => driver.resolveWithheldFilterRefusal(...a), + sqliteCanonicalDatetimeSql: (...a) => driver.sqliteCanonicalDatetimeSql(...a), + sqliteCanonicalTimeSql: (...a) => driver.sqliteCanonicalTimeSql(...a), + storageDatetimeValue: (...a) => driver.storageDatetimeValue(...a), + tableNameForBuilder: (...a) => driver.tableNameForBuilder(...a), + temporalFieldKind: (...a) => driver.temporalFieldKind(...a), + get timeFields() { return driver.timeFields; }, + toDateOnly: (...a) => driver.toDateOnly(...a), + toTimeOnly: (...a) => driver.toTimeOnly(...a), + get valueShapeFields() { return driver.valueShapeFields; }, + })); + internalHelpersHost.install((driver) => ({ + applyFilters: (...a) => driver.applyFilters(...a), + applyTenantScope: (...a) => driver.applyTenantScope(...a), + get getBuilder() { return driver.getBuilder.bind(driver); }, + isMultiTenantMode: (...a) => driver.isMultiTenantMode(...a), + get knex() { return driver.knex; }, + get logger() { return driver.logger; }, + remoteColumn: (...a) => driver.remoteColumn(...a), + resolveTenantField: (...a) => driver.resolveTenantField(...a), + get tenantAuditWarned() { return driver.tenantAuditWarned; }, + get tenantFieldByTable() { return driver.tenantFieldByTable; }, + })); + lifecycleHost.install((driver) => ({ + applyAutoVacuumIncremental: (...a) => driver.applyAutoVacuumIncremental(...a), + applySqliteJournalMode: (...a) => driver.applySqliteJournalMode(...a), + get config() { return driver.config; }, + get declaredJournalMode() { return driver.declaredJournalMode; }, + ensureDatabaseExists: (...a) => driver.ensureDatabaseExists(...a), + get isSqlite() { return driver.isSqlite; }, + get knex() { return driver.knex; }, + get logger() { return driver.logger; }, + releaseRefusedSqliteConnection: (...a) => driver.releaseRefusedSqliteConnection(...a), + resolveSqliteJournalMode: (...a) => driver.resolveSqliteJournalMode(...a), + sqliteFilename: (...a) => driver.sqliteFilename(...a), + get supportsWalJournal() { return driver.supportsWalJournal; }, + verifyWalReadable: (...a) => driver.verifyWalReadable(...a), + })); + managedDriftHost.install((driver) => ({ + introspectIndexes: (...a) => driver.introspectIndexes(...a), + get isMysql() { return driver.isMysql; }, + get isPostgres() { return driver.isPostgres; }, + get knex() { return driver.knex; }, + syncDeclaredIndexes: (...a) => driver.syncDeclaredIndexes(...a), + uniqueIndexesFromFields: (...a) => driver.uniqueIndexesFromFields(...a), + })); + queryPlanHost.install((driver) => ({ + analyzeQuery: (...a) => driver.analyzeQuery(...a), + applyFilters: (...a) => driver.applyFilters(...a), + applyTenantScope: (...a) => driver.applyTenantScope(...a), + get config() { return driver.config; }, + get getBuilder() { return driver.getBuilder.bind(driver); }, + get isMysql() { return driver.isMysql; }, + get isPostgres() { return driver.isPostgres; }, + get isSqlite() { return driver.isSqlite; }, + get knex() { return driver.knex; }, + mapSortField: (...a) => driver.mapSortField(...a), + })); + rotationHost.install((driver) => ({ + assertSchemaMutable: (...a) => driver.assertSchemaMutable(...a), + get autoNumberFields() { return driver.autoNumberFields; }, + get booleanFields() { return driver.booleanFields; }, + computeAndRecordTenantField: (...a) => driver.computeAndRecordTenantField(...a), + computeTenantField: (...a) => driver.computeTenantField(...a), + get dateFields() { return driver.dateFields; }, + get datetimeFields() { return driver.datetimeFields; }, + get dialectName() { return driver.dialectName; }, + ensureRotation: (...a) => driver.ensureRotation(...a), + get fractionalNumericFields() { return driver.fractionalNumericFields; }, + isJsonField: (...a) => driver.isJsonField(...a), + get jsonFields() { return driver.jsonFields; }, + get knex() { return driver.knex; }, + get managedObjectFields() { return driver.managedObjectFields; }, + get managedObjectIndexes() { return driver.managedObjectIndexes; }, + get mediaFields() { return driver.mediaFields; }, + get numericFields() { return driver.numericFields; }, + get numericValueFields() { return driver.numericValueFields; }, + get organizationObjectRegistered() { return driver.organizationObjectRegistered; }, + set organizationObjectRegistered(v) { driver.organizationObjectRegistered = v; }, + get physicalKeyIndexes() { return driver.physicalKeyIndexes; }, + registerManagedObjectMetadata: (...a) => driver.registerManagedObjectMetadata(...a), + get rotationStateByTable() { return driver.rotationStateByTable; }, + get supportsRotation() { return driver.supportsRotation; }, + get tablesWithTimestamps() { return driver.tablesWithTimestamps; }, + get tenantFieldByTable() { return driver.tenantFieldByTable; }, + get tenantOptOutByTable() { return driver.tenantOptOutByTable; }, + get timeFields() { return driver.timeFields; }, + get undeclaredAuditTimestampFields() { return driver.undeclaredAuditTimestampFields; }, + get updatedAtColumnState() { return driver.updatedAtColumnState; }, + get valueShapeFields() { return driver.valueShapeFields; }, + })); + schemaSyncSectionHost.install((driver) => ({ + assertSchemaMutable: (...a) => driver.assertSchemaMutable(...a), + initObjects: (...a) => driver.initObjects(...a), + get isSqlite() { return driver.isSqlite; }, + get knex() { return driver.knex; }, + })); + sequenceSemanticsHost.install((driver) => ({ + applyTenantScope: (...a) => driver.applyTenantScope(...a), + applyWriteColumnMap: (...a) => driver.applyWriteColumnMap(...a), + assertConflictTargetBackedWithoutClause: (...a) => driver.assertConflictTargetBackedWithoutClause(...a), + assertConflictTargetHonoured: (...a) => driver.assertConflictTargetHonoured(...a), + assertMergeLandedInWrittenOrganization: (...a) => driver.assertMergeLandedInWrittenOrganization(...a), + assertMergeLandedOnSuppliedIdentity: (...a) => driver.assertMergeLandedOnSuppliedIdentity(...a), + auditMissingTenant: (...a) => driver.auditMissingTenant(...a), + get autoNumberFields() { return driver.autoNumberFields; }, + collidingAutoNumberReservations: (...a) => driver.collidingAutoNumberReservations(...a), + createSequencesTable: (...a) => driver.createSequencesTable(...a), + defineSequencesTable: (...a) => driver.defineSequencesTable(...a), + escapeLikePrefix: (...a) => driver.escapeLikePrefix(...a), + fillAutoNumberFields: (...a) => driver.fillAutoNumberFields(...a), + formatInput: (...a) => driver.formatInput(...a), + formatOutput: (...a) => driver.formatOutput(...a), + get getBuilder() { return driver.getBuilder.bind(driver); }, + injectTenantOnInsert: (...a) => driver.injectTenantOnInsert(...a), + insertOnlyUpsertColumns: (...a) => driver.insertOnlyUpsertColumns(...a), + get isMysql() { return driver.isMysql; }, + get isPostgres() { return driver.isPostgres; }, + get isSqlite() { return driver.isSqlite; }, + keepSuppliedUpdatedAt: (...a) => driver.keepSuppliedUpdatedAt(...a), + get knex() { return driver.knex; }, + get logger() { return driver.logger; }, + maxAutonumberCounter: (...a) => driver.maxAutonumberCounter(...a), + mergeLandsInWrittenOrganization: (...a) => driver.mergeLandsInWrittenOrganization(...a), + observedUpdatedAtColumn: (...a) => driver.observedUpdatedAtColumn(...a), + get organizationObjectRegistered() { return driver.organizationObjectRegistered; }, + get physicalTableByObject() { return driver.physicalTableByObject; }, + remoteColumn: (...a) => driver.remoteColumn(...a), + resolveSequenceTenantId: (...a) => driver.resolveSequenceTenantId(...a), + resolveTenantField: (...a) => driver.resolveTenantField(...a), + resyncSequenceToDataMax: (...a) => driver.resyncSequenceToDataMax(...a), + rotatedUpdateById: (...a) => driver.rotatedUpdateById(...a), + rotationShardsOf: (...a) => driver.rotationShardsOf(...a), + rotationWriteTarget: (...a) => driver.rotationWriteTarget(...a), + sequenceKeyHash: (...a) => driver.sequenceKeyHash(...a), + get sequencesHasKeyHash() { return driver.sequencesHasKeyHash; }, + set sequencesHasKeyHash(v) { driver.sequencesHasKeyHash = v; }, + stampInsertTimestamps: (...a) => driver.stampInsertTimestamps(...a), + stampUpsertUpdatedAt: (...a) => driver.stampUpsertUpdatedAt(...a), + stampsUpdatedAt: (...a) => driver.stampsUpdatedAt(...a), + get tablesWithTimestamps() { return driver.tablesWithTimestamps; }, + updateWithPresumedTimestamp: (...a) => driver.updateWithPresumedTimestamp(...a), + get updatedAtColumnState() { return driver.updatedAtColumnState; }, + updatedAtStamp: (...a) => driver.updatedAtStamp(...a), + updatedAtStampIsPresumed: (...a) => driver.updatedAtStampIsPresumed(...a), + upsertTenantGuard: (...a) => driver.upsertTenantGuard(...a), + })); + sqliteSerialisationHost.install((driver) => ({ + get booleanFields() { return driver.booleanFields; }, + get columnFieldByObject() { return driver.columnFieldByObject; }, + get dateFields() { return driver.dateFields; }, + get datetimeFields() { return driver.datetimeFields; }, + get fileColumnsMoved() { return driver.fileColumnsMoved; }, + get isMysql() { return driver.isMysql; }, + get isSqlite() { return driver.isSqlite; }, + get jsonFields() { return driver.jsonFields; }, + get mediaFields() { return driver.mediaFields; }, + get numericFields() { return driver.numericFields; }, + get numericValueFields() { return driver.numericValueFields; }, + storageDatetimeValue: (...a) => driver.storageDatetimeValue(...a), + get timeFields() { return driver.timeFields; }, + toDateOnly: (...a) => driver.toDateOnly(...a), + toTimeOnly: (...a) => driver.toTimeOnly(...a), + get undeclaredAuditTimestampFields() { return driver.undeclaredAuditTimestampFields; }, + })); + transactionsSectionHost.install((driver) => ({ + get activeTransactions() { return driver.activeTransactions; }, + set activeTransactions(v) { driver.activeTransactions = v; }, + get openTransactions() { return driver.openTransactions; }, + })); + windowFunctionBuilderHost.install((driver) => ({ + get knex() { return driver.knex; }, + mapSortField: (...a) => driver.mapSortField(...a), + })); + windowFunctionsHost.install((driver) => ({ + aliasIdentifierSql: (...a) => driver.aliasIdentifierSql(...a), + applyFilters: (...a) => driver.applyFilters(...a), + applyTenantScope: (...a) => driver.applyTenantScope(...a), + buildWindowFunction: (...a) => driver.buildWindowFunction(...a), + formatOutput: (...a) => driver.formatOutput(...a), + get getBuilder() { return driver.getBuilder.bind(driver); }, + get knex() { return driver.knex; }, + mapSortField: (...a) => driver.mapSortField(...a), + })); + } } /** diff --git a/scripts/check-tenant-chokepoint.mjs b/scripts/check-tenant-chokepoint.mjs index 9a24ac68983..e5f04a82a56 100644 --- a/scripts/check-tenant-chokepoint.mjs +++ b/scripts/check-tenant-chokepoint.mjs @@ -253,12 +253,15 @@ const DECLARED_WATCH_HINTS = [ 'packages/drivers/driver-sql/src/sql-driver-autonumber.ts', 'packages/drivers/driver-sql/src/sql-driver-balanced-group.ts', 'packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts', + 'packages/drivers/driver-sql/src/sql-driver-class-head.ts', 'packages/drivers/driver-sql/src/sql-driver-column-creation.ts', 'packages/drivers/driver-sql/src/sql-driver-cross-field-comparison.ts', 'packages/drivers/driver-sql/src/sql-driver-crud.ts', 'packages/drivers/driver-sql/src/sql-driver-database-helpers.ts', 'packages/drivers/driver-sql/src/sql-driver-date-bucket.ts', + 'packages/drivers/driver-sql/src/sql-driver-deferred-ddl.ts', 'packages/drivers/driver-sql/src/sql-driver-distinct.ts', + 'packages/drivers/driver-sql/src/sql-driver-facet-hosts.ts', 'packages/drivers/driver-sql/src/sql-driver-field-mapping.ts', 'packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts', 'packages/drivers/driver-sql/src/sql-driver-internal-helpers.ts', @@ -277,6 +280,7 @@ const DECLARED_WATCH_HINTS = [ 'packages/drivers/driver-sql/src/sql-driver-sqlite-serialisation.ts', 'packages/drivers/driver-sql/src/sql-driver-top-level.ts', 'packages/drivers/driver-sql/src/sql-driver-transactions.ts', + 'packages/drivers/driver-sql/src/sql-driver-transactions-section.ts', 'packages/drivers/driver-sql/src/sql-driver-upsert-conflict-target.ts', 'packages/drivers/driver-sql/src/sql-driver-window-function-builder.ts', 'packages/drivers/driver-sql/src/sql-driver-window-functions.ts', diff --git a/scripts/codemod-split-sql-driver.mjs b/scripts/codemod-split-sql-driver.mjs index 0b4f38e87a8..ab7402b5200 100644 --- a/scripts/codemod-split-sql-driver.mjs +++ b/scripts/codemod-split-sql-driver.mjs @@ -2,40 +2,68 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * codemod-split-sql-driver — move whole sections of `SqlDriver`'s method - * bodies out of `packages/drivers/driver-sql/src/sql-driver.ts` into section - * modules, deterministically, and prove the move. + * codemod-split-sql-driver — put `SqlDriver` in + * `packages/drivers/driver-sql/src/sql-driver.ts` into facade form: move whole + * sections of its method bodies into stateless facet classes, one per section + * module, move the top-level declarations ahead of the class into one leaf + * module, deterministically, and prove the move. * * node scripts/codemod-split-sql-driver.mjs # plan: print the section table, write nothing * node scripts/codemod-split-sql-driver.mjs --write # apply the split, then verify it * node scripts/codemod-split-sql-driver.mjs --verify --base * # re-prove a split on disk against sql-driver.ts at - * node scripts/codemod-split-sql-driver.mjs --dts - * # the declaration-file bar on two built dists * node scripts/codemod-split-sql-driver.mjs --self-test * node scripts/codemod-split-sql-driver.mjs --help # this usage, on any tree (split or not) * + * The input is always an UNSPLIT `sql-driver.ts`: the last one on `main` is + * the parent of the pilot's landing (`git show 21510bba:`). Copy it over + * the file, then run `--write`; `--verify --base 21510bba` re-proves the tree. + * At landing the script runs again on the unsplit text of the latest `main` + * (ruling A on #29465: regenerate, never rebase); while no commit after the + * pilot touches `sql-driver.ts` or a module it generated, that text is + * 21510bba's, which the pilot's `--verify --base 21510bba` on `main` confirms. + * * ## Why a script, and what it repeats * * The hand series that split this file cut by cut (21 cuts) settled the cut - * pattern; this script applies it to whole sections at once, so the split can - * be regenerated on the latest `main` instead of rebased (ruling A on the - * codemod card, `6583399215`). The pattern, unchanged: + * pattern, and the pilot (#29520, ruling A `6583399215`) applied it to whole + * sections. The facade form (ruling `6624258595` item 1, extended to this file + * by `6632500982`) is the form `engine.ts` (#29986, #30051) and `protocol.ts` + * (#29985) took; this script is the pilot with that layer added, the way + * `scripts/codemod-split-engine.mjs` adds it: * - * - The method stays on the class with its docblock, modifiers and signature - * byte-for-byte, so the published `.d.ts` keeps its names and positions and - * an instance spy or a subclass override is still the one callers reach. - * Only `async` is dropped from a stub, as every async stub of the series did. - * - The body moves, unedited, into an exported function of the same name in - * `sql-driver-
.ts`, whose `this` is one named host: the `SqlDriver` + * - The method stays on the class with its modifiers and signature + * byte-for-byte, so the published surface keeps its names and an instance + * spy or a subclass override is still the one callers reach. Only `async` + * is dropped from a stub, as every async stub of the series did. + * - The body moves, unedited, into a static method of the same name on the + * section's facet class (`CrudFacet` in `sql-driver-crud.ts`), at the + * class's own indentation, whose `this` is one named host: the `SqlDriver` * members the body reads, typed AS the class members (`SqlDriver['x']`). - * - The stub hands the body that host: a method as a closure that calls it on - * the driver at call time (the builder factory as a bound reference, see - * `BOUND_HOST_METHODS`), a field or accessor as a GETTER that reads it when - * the body reads it. + * A private member is reached the same way as a public one: the indexed + * access type names it, and the host factory, inside the class, reads it. + * - Docblock ownership (ruling `6624345766` item 1): a PRIVATE method's + * docblock moves with its body; a public or protected one stays on the + * facade, its contract, and the facet member carries a one-line + * placeholder pointing back to it. + * - The stub is ONE call, `Facet.x.call(xHost.of(this), …)`: the host is one + * object per facet and driver (`HOSTS_MODULE`, built on first use by a + * factory the class's static block installs), in which a method goes as a + * closure that calls it on the driver at call time (the builder factory as + * a bound reference, `BOUND_HOST_METHODS`), a field or accessor as a GETTER + * read when the body reads it, and a field a body assigns with a SETTER. + * - A static the moved bodies read that is not public goes through the host + * module of the class (private-static-through-host, ruling `6624174693`): + * `SqlDriverStatics`, declared ahead of the class and filled by a second + * static block, which a facet module imports AS `SqlDriver`, so the body + * reads `SqlDriver.x` unedited. A static method whose body reads no `this` + * moves like an instance one, its stub calling the facet directly. + * - The state stays on `SqlDriver`; a body that owns state in a way a host + * cannot forward stays on the class, and the facade gate + * (`scripts/check-facade-delegation-only.mjs`) names it as an exception. * * Ruling `6591753301` (option A) widened the pattern two ways, and this script - * applies both: + * applies both, as the pilot did: * * - (a) A section module may import VALUES from `sql-driver.ts`. A staying * top-level name a moved body reads is exported for it by one generated @@ -52,8 +80,14 @@ * `./sql-driver.js` (`index.ts`, the hand-series seams, the tests) reads * what it read. * + * Sections are read off the class's banners (`SECTION_MODULES`); the members + * above the first banner are the class head (`CLASS_HEAD_SECTION`). The + * package compiles without `noUnusedLocals`, so, unlike `engine.ts`, no import + * is pruned: an import only a moved body reads stays in `sql-driver.ts`, as + * the pilot left them, and the undo needs no recomputed edit. + * * `check-tenant-chokepoint` reads every `sql-driver-*.ts` module since the - * same ruling, so a body carrying the builder factory or the tenant scope + * pilot's ruling, so a body carrying the builder factory or the tenant scope * moves like any other; its readings must be equal before and after. * * ⛔ The moved text is not re-indented and not formatted: the body is the @@ -63,60 +97,62 @@ * ## What stays, and why (each reason is mechanical) * * A method stays whole on the class when moving it would change what it does, - * what the published `.d.ts` says, or what a gate or test that reads + * what the published surface says, or what a gate or test that reads * `sql-driver.ts` BY PATH sees. `STAY_REASONS` lists every reason for a method * and `TOP_STAY_REASONS` every reason for a top-level declaration; the plan * prints every one that stayed and why. The reasons that are tables rather * than rules are `PATH_READER_PINS` (a name whose text a reader extracts from * `sql-driver.ts` by path), `PATH_READER_NEEDLES` (literal text a reader * requires to find there) and `CLASS_PATH_READER_NEEDLES` (the same, for a - * reader that also reads the leaf, so only a method carrying it stays). + * reader that also reads the leaf, so only a method carrying it stays). Ruling + * `6597555122` item 3: a reader that can read the new modules gains them in + * its reading list in the same PR instead, and needs no row here. * * ## The proof (`--write` runs it on its own output; `--verify` re-runs it) * - * 1. Byte-identity: every generated function's body, parameters and return - * type are the base method's text, exactly; every leaf declaration is its - * base statement's text with the comments above it, exactly; and putting - * every body back (and `async` back on its stub), taking the generated - * imports and exports out, and splicing the leaf back between the - * statements that stayed rebuilds the base `sql-driver.ts` byte-for-byte, - * so nothing else in it moved. The leaf's relative imports are followed on - * disk: none may reach `sql-driver.ts`. - * 2. The declaration-file bar of ruling `6348891160` item 1, on the built - * `dist/index.d.ts` and `dist/index.d.mts` (`--dts`): the same declaration - * set; byte-identical after sorting the members of each string-literal - * union (the ruling's one normalisation, `normaliseUnionOrder`); the same - * export list; and a positive control (one private member made public) - * that the normaliser does not hide. + * 1. Byte-identity: every facet member's body, parameters, type parameters + * and return type are the base method's text, exactly; a moved docblock is + * the base method's docblock, exactly, and only a private method's; every + * stub is exactly the one-call stub the generator writes; the host module, + * the statics forwarders and both static blocks are exactly what the + * generator writes for what they name; every leaf declaration is its base + * statement's text with the comments above it, exactly; and undoing all of + * it rebuilds the base `sql-driver.ts` byte-for-byte, so nothing else in it + * moved. The leaf's relative VALUE imports are followed on disk: none may + * reach `sql-driver.ts` or a `sql-driver-*` module. + * 2. The declaration-file bar for the facade form is ruling `6624345766`'s + * PUBLIC SURFACE EQUAL (exported names, public and protected members and + * their printed types; private members may change), a report reading on + * two built dists, never a `scripts/` file (ruling `6441032442` item 3). + * The pilot's `--dts` judged byte-equality after union sorting (ruling + * `6348891160`), which a facade cannot meet by construction (the bundled + * `.d.ts` prints every private member), so this script no longer carries + * it; the PR reports the public-surface reading. * * ## Determinism * * The input is the text of `sql-driver.ts`. It is parsed in an in-memory * project (no `node_modules`, no build output, no clock), every collection is * emitted in source order or sorted, and the output is a pure function of the - * input: two runs on one input write byte-identical trees. + * input: two runs on one input write byte-identical trees (the plan prints the + * output tree's sha256). Only the proof's leaf-reach walk reads sibling files. * * ts-morph is a root devDependency (`package.json` declares `ts-morph` * `^28.0.0`), the way the other root scripts take `typescript`, `yaml` or * `semver`: it is resolved from the root manifest, so a fresh - * `pnpm install --frozen-lockfile` is all this script needs. It used to borrow - * `@objectstack/cli`'s closure; #29711 dropped `ts-morph` from the CLI, and no - * workspace package declares it. + * `pnpm install --frozen-lockfile` is all this script needs. * * Exit codes: 0 done · 1 a proof failed (stop: the generated split is never * hand-edited to pass) · 2 the script could not run (unknown banner, missing * prerequisite, bad arguments) · 3 an input does not parse (`ts-parse.mjs`'s - * refusal, which names the parse errors). A tree with no install at all stops - * before this script's own ts-morph check: `ts-parse.mjs` loads `typescript` - * through `import-prerequisite.mjs`, whose refusal is also exit 3 and names - * `pnpm install`. + * refusal, which names the parse errors). */ import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { join } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; @@ -129,10 +165,14 @@ const HOST_BASENAME = 'sql-driver.ts'; const HOST_PATH = join(SRC_DIR, HOST_BASENAME); const CLASS_NAME = 'SqlDriver'; const CARD = '#29520'; +const SCRIPT = 'scripts/codemod-split-sql-driver.mjs'; /** The marker every generated file and stub carries; `--verify` keys on it. */ const MARK = `[${CARD} codemod]`; +/** A generated or hand-series section module of `sql-driver.ts`, by its import specifier. */ +const SEAM_SPECIFIER = /^\.\/sql-driver-[a-z0-9-]+\.js$/; + /** * Every banner inside `SqlDriver`, in the file's two spellings * (`// ====` / `// Title` / `// ====`, and `// ── Title ───`), mapped to the @@ -172,13 +212,32 @@ const SECTION_MODULES = Object.freeze({ }); /** - * The leaf module the top-level declarations ahead of the class move to, whole + * The members declared above the class's first banner (its fields, the + * constructor, the dialect probes and the logger plumbing) form one more + * section, the class head, named here rather than by a banner because the + * file carries none for it. `null` would keep it on the class. + */ +const CLASS_HEAD_SECTION = Object.freeze(['Class head: dialect probes, logger, configuration', 'sql-driver-class-head.ts']); + +/** + * The leaf the top-level declarations ahead of the class move to, whole * (lever (b) of ruling `6591753301`). A leaf: it never imports `sql-driver.ts` * or any `sql-driver-*` module, so it is evaluated before the class and forms * no cycle with it. */ const LEAF_MODULE = 'sql-driver-top-level.ts'; +/** + * The module that holds each facet's host, one per driver (#30105, the + * facet-host form `engine.ts` took in #30051): a `FacetHost` per facet module, + * built on first use by the factory the class's static block installs and kept + * for the driver's life. It imports types only, so it is evaluated before the + * class whichever module is loaded first, and the class's static block can + * install into it. + */ +const HOSTS_MODULE = 'sql-driver-facet-hosts.ts'; +const HOSTS_CLASS = 'FacetHost'; + /** * Names whose text a reader takes out of `sql-driver.ts` BY PATH. Moving it * would leave that reader reading a stub, or nothing. A class method or a @@ -190,6 +249,11 @@ const PATH_READER_PINS = Object.freeze({ createColumn: 'packages/cli generate-{field-type-vocabulary,string-family-width,multiple-json-column} pins and ' + 'scripts/check-keyed-text-bounds.mjs slice `protected createColumn(` to its type switch', createAuditTimestampColumn: 'packages/cli generate-builtin-id-column pin reads 600 characters after its signature', + // The durability catches that stay (the `this.runWideningAlters(` row + // below) are judged by following `this.logDurabilityFailure(…)` to its + // body in the same file; a one-call stub there is a call that gate cannot + // follow, so both catches read "swallowed, no log" (measured, #30105). + logDurabilityFailure: 'scripts/check-durability-degradation-log-level.mjs follows a catch\'s log helper to its body in the same file', }); /** @@ -244,35 +308,29 @@ const CLASS_PATH_READER_NEEDLES = Object.freeze({ /** * The `SqlDriver` methods a host hands its body as a BOUND reference - * (`this.m.bind(this)`) rather than as a closure that calls it. Only the - * builder factory: `check-tenant-chokepoint` refuses a `this.getBuilder(...)` - * call whose result is never bound to a name, and a forwarding closure in a - * stub is exactly that shape. The bound reference calls the same method on - * the same driver (a subclass override included); the real call, and the - * binding the gate counts, stay in the moved body, where the widened gate - * reads them. + * (`driver.m.bind(driver)`, through a getter) rather than as a closure that + * calls it. Only the builder factory: `check-tenant-chokepoint` refuses a + * `this.getBuilder(...)` call whose result is never bound to a name, and a + * forwarding closure is exactly that shape. The bound reference calls the + * same method on the same driver (a subclass override included); the real + * call, and the binding the gate counts, stay in the moved body, where the + * widened gate reads them. */ const BOUND_HOST_METHODS = new Set(['getBuilder']); -/** Reserved words a function declaration may not be named. */ -const RESERVED = new Set(('break case catch class const continue debugger default delete do else enum export extends ' + - 'false finally for function if import in instanceof new null return super switch this throw true try typeof var ' + - 'void while with implements interface let package private protected public static yield await').split(' ')); - /** Every reason a method stays, with the one-line why the plan prints. */ const STAY_REASONS = Object.freeze({ - 'no-section': 'declared before the first section banner', + 'no-section': 'declared above the first section banner, and the class head maps to no module', 'section-kept': 'its section maps to no module', - 'static': 'a static member (the host is an instance)', + 'static-this': 'a static method whose body reads `this` (the class; a facet static has its own)', + 'static-name-shared': 'a static method whose name an instance member also takes (one facet cannot hold both)', 'no-body': 'an overload signature or abstract member', 'generator': 'a generator', 'overloaded': 'an overloaded method', 'decorated': 'a decorated method', 'not-identifier-name': 'a computed, string or #private name', - 'reserved-name': 'its name is reserved for a function declaration', 'inferred-return-type': 'a non-private method whose return type is inferred (the .d.ts would infer it through the module)', - 'type-parameters': 'a generic method (Function#call does not carry its type parameters)', - 'parameter-shape': 'a destructured, defaulted or `this` parameter (the stub cannot forward it unchanged)', + '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`', @@ -281,17 +339,18 @@ const STAY_REASONS = Object.freeze({ 'this-type': 'it names the polymorphic `this` type', 'bare-this': 'it uses `this` other than as `this.member`', 'unknown-member': 'it reads a `this.member` the class does not declare', - 'this-write': 'it assigns a `this.member` (a host getter cannot be written)', + 'this-write': 'it assigns a `this.member` that is not a field, or deletes one (the host carries a setter for a field only)', 'method-as-value': 'it reads a method as a value instead of calling it', 'field-call': 'it calls a field or accessor as a function (the host would change its receiver)', 'overloaded-host-method': 'it calls an overloaded method (the host closure cannot be typed)', - 'non-public-static': 'it reads a private or protected static of SqlDriver (unreachable from a module function)', + 'static-write': 'its static reads go through SqlDriverStatics and it assigns a static (the forwarder is read-only)', + 'static-as-value': 'its static reads go through SqlDriverStatics and it reads a static method as a value (a forwarder is another function)', + 'static-field-call': 'its static reads go through SqlDriverStatics and it calls a static field or accessor (the receiver would change)', + 'overloaded-static': 'its static reads go through SqlDriverStatics and it calls an overloaded static (one forwarder cannot carry the overloads)', + 'static-route-conflict': 'its facet module reads the statics through SqlDriverStatics, imported AS `SqlDriver`, and it names `SqlDriver` other than as `SqlDriver.`', 'module-write': 'it assigns a module-scope binding of sql-driver.ts (an import is read-only)', 'already-moved': 'its body is already a hand-series seam stub', - 'name-taken': 'its name is already a module-scope binding in sql-driver.ts', - 'name-collision': 'its name collides with an import its module needs', - 'parameter-shadows': 'a parameter shadows the moved function or a stub local', - 'no-yield': 'its stub would be no shorter than its body (moving it would grow sql-driver.ts)', + 'parameter-shadows': 'a parameter shadows a name its one-call stub reads (its facet class or its facet\'s host)', }); /** Every reason a top-level declaration ahead of the class stays in sql-driver.ts. */ @@ -299,7 +358,7 @@ const TOP_STAY_REASONS = Object.freeze({ 'top-kind': 'not a const / function / interface / type declaration of plain names', 'merged-declaration': 'its name is declared by more than one statement (an overload set or a merged declaration)', 'path-reader': 'a reader takes its text out of sql-driver.ts by path (PATH_READER_PINS / PATH_READER_NEEDLES)', - 'class-reference': 'it names the class (the leaf would import sql-driver.ts back)', + 'class-reference': 'it names a class sql-driver.ts declares (the leaf would import sql-driver.ts back)', 'seam-import': 'it uses an import from a sql-driver-* module (the leaf would reach sql-driver.ts back through it)', 'needs-stayer': 'it names a declaration that stays (the leaf would import sql-driver.ts back)', }); @@ -371,6 +430,29 @@ function readBanners(lines, fromLine, toLine) { return banners; } +/** + * Each member's section: the last banner above it, or the class head above + * the first banner. Refuses a banner `sections` does not name. Returns a + * function from a member node to its `[title, module]` row (the module `null` + * when the section stays on the class), or null when `head` is null and the + * member is above the first banner. + */ +function readSections(text, cls, sections, head) { + const banners = readBanners(text.split('\n'), cls.getStartLineNumber(), cls.getEndLineNumber()); + const unknown = banners.filter((b) => !Object.prototype.hasOwnProperty.call(sections, b.title)); + if (unknown.length > 0) { + refuse(`banner(s) the section table does not name: ${unknown.map((b) => `"${b.title}" (line ${b.line})`).join(', ')}. ` + + 'Add each to SECTION_MODULES (a module name, or null to keep the section on the class).'); + } + return (member) => { + const startLine = member.getStartLineNumber(true); + let banner = null; + for (const b of banners) if (b.line < startLine) banner = b; + if (!banner) return head; + return [banner.title, sections[banner.title]]; + }; +} + // ── Analysis ───────────────────────────────────────────────────────────────── /** The names sql-driver.ts binds at module scope, and which of them it exports. */ @@ -507,26 +589,35 @@ function readMethod(tm, ctx, m) { if (!reasons.includes(tag)) reasons.push(tag); }; const host = new Map(); // member -> 'method' | 'getter' + const writes = new Set(); // fields the body assigns: the host carries a setter for each const imports = new Map(); // local -> { value } const moduleRefs = new Map(); // sql-driver.ts top-level name -> { value } - - if (m.isStatic()) add('static'); + // The private-static-through-host rule: the statics the body reads as + // `SqlDriver.x`, whether one is non-public (that takes the route), what the + // route cannot carry, and whether the body names `SqlDriver` any other way. + const classStatics = new Map(); // static -> 'method' | 'getter' + const routeBlockers = []; + let readsNonPublicStatic = false; + let classOther = false; + const result = () => ({ reasons, host, writes, imports, moduleRefs, classStatics, routeBlockers, readsNonPublicStatic, classOther }); + + 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 (m.getDecorators().length > 0) add('decorated'); const nameNode = m.getNameNode(); - if (!Node.isIdentifier(nameNode)) { add('not-identifier-name'); return { reasons, host, imports, moduleRefs }; } + if (!Node.isIdentifier(nameNode)) { add('not-identifier-name'); return result(); } const name = nameNode.getText(); - if (RESERVED.has(name)) add('reserved-name'); if (!m.getReturnTypeNode() && m.getScope() !== 'private') add('inferred-return-type'); - if (m.getTypeParameters().length > 0) add('type-parameters'); for (const p of m.getParameters()) { - if (!Node.isIdentifier(p.getNameNode()) || p.hasInitializer() || p.getName() === 'this') add('parameter-shape', p.getName()); + // A defaulted identifier forwards as it is: the stub keeps the default and + // hands the body the value it produced. + if (!Node.isIdentifier(p.getNameNode()) || p.getName() === 'this') add('parameter-shape', p.getName()); } const body = m.getBody(); - if (!body) return { reasons, host, imports, moduleRefs }; + if (!body) return result(); // A comment between the parentheses that no parameter's own text carries. const params = m.getParameters(); @@ -554,6 +645,7 @@ function readMethod(tm, ctx, m) { if (Node.isMetaProperty(node) && node.getKeywordToken() === SyntaxKind.NewKeyword) add('new-target'); if (kind === SyntaxKind.ThisKeyword) { if (thisContainer(tm, node) !== m) continue; + if (m.isStatic()) { add('static-this'); continue; } const parent = node.getParent(); if (!Node.isPropertyAccessExpression(parent) || parent.getExpression() !== node) { add('bare-this'); continue; } const memberNameNode = parent.getNameNode(); @@ -561,7 +653,12 @@ function readMethod(tm, ctx, m) { const member = memberNameNode.getText(); const info = ctx.members.get(member); if (!info) { add('unknown-member', member); continue; } - if (isWritten(tm, parent)) add('this-write', member); + if (isWritten(tm, parent)) { + // A field is written through a host setter; a method, an accessor or + // a `delete` cannot be. + if (info.kind === 'field' && !Node.isDeleteExpression(parent.getParent())) writes.add(member); + else add('this-write', member); + } const outer = parent.getParent(); const called = Node.isCallExpression(outer) && outer.getExpression() === parent; if (info.kind === 'method') { @@ -606,12 +703,23 @@ function readMethod(tm, ctx, m) { // for it) when it stayed. Two things a module function cannot do: // reach a non-public static of the class, and assign an import. const declName = decl.name && ts.isIdentifier(decl.name) ? decl.name.text : node.getText(); - if (ts.isClassDeclaration(decl) && declName === CLASS_NAME && !typePos) { + if (ts.isClassDeclaration(decl) && declName === CLASS_NAME) { const access = node.getParent(); - if (Node.isPropertyAccessExpression(access) && access.getExpression() === node) { + const info = !typePos && Node.isPropertyAccessExpression(access) && access.getExpression() === node + ? ctx.statics.get(access.getName()) : null; + if (info) { const member = access.getName(); - const scope = ctx.statics.get(member); - if (scope && scope !== 'public') add('non-public-static', member); + if (info.scope !== 'public') readsNonPublicStatic = true; + const outer = access.getParent(); + const called = Node.isCallExpression(outer) && outer.getExpression() === access; + const block = (tag) => { if (!routeBlockers.includes(tag)) routeBlockers.push(tag); }; + if (isWritten(tm, access) || info.kind === 'setter') block(`static-write: ${member}`); + if (info.kind === 'method' && !called) block(`static-as-value: ${member}`); + if (info.kind === 'method' && info.overloaded) block(`overloaded-static: ${member}`); + if (info.kind !== 'method' && called) block(`static-field-call: ${member}`); + classStatics.set(member, info.kind === 'method' ? 'method' : 'getter'); + } else { + classOther = true; } } if (!typePos && isWritten(tm, node)) add('module-write', declName); @@ -622,15 +730,15 @@ function readMethod(tm, ctx, m) { } const binding = ctx.scope.bindings.get(name); - if (binding) { - if (binding.kind === 'import' && /^\.\/sql-driver-[a-z0-9-]+\.js$/.test(binding.specifier)) add('already-moved', binding.specifier); - else add('name-taken'); - } - for (const p of params) { - const pn = p.getName(); - if (pn === name || pn === 'host' || /^read[A-Z]/.test(pn)) add('parameter-shadows', pn); - } - return { reasons, host, imports, moduleRefs }; + if (binding && binding.kind === 'import' && SEAM_SPECIFIER.test(binding.specifier)) add('already-moved', binding.specifier); + // A body that reads a non-public static takes the route; what the route + // cannot carry keeps it on the class. (A module another mover routes + // applies the same to every mover in it: `plan`.) + if (readsNonPublicStatic) { + for (const tag of routeBlockers) add(tag); + if (classOther) add('static-route-conflict'); + } + return result(); } function isModuleScopeDecl(ts, decl) { @@ -650,12 +758,158 @@ function isModuleScopeDecl(ts, decl) { // ── The plan ───────────────────────────────────────────────────────────────── -/** `sql-driver-raw-execution-section.ts` -> `RawExecutionSectionHost`. */ +/** `sql-driver-transactions-section.ts` -> `TransactionsSectionHost`. */ function hostNameOf(moduleFile) { const slug = moduleFile.replace(/^sql-driver-/, '').replace(/\.ts$/, ''); return `${slug.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join('')}Host`; } +/** `sql-driver-transactions-section.ts` -> `TransactionsSectionFacet`: the stateless class the bodies move into. */ +function facetNameOf(moduleFile) { + return hostNameOf(moduleFile).replace(/Host$/, 'Facet'); +} + +/** `sql-driver-transactions-section.ts` -> `transactionsSectionHost`: the module's `FacetHost` in `HOSTS_MODULE`. */ +function hostVarOf(moduleFile) { + const name = hostNameOf(moduleFile); + return `${name[0].toLowerCase()}${name.slice(1)}`; +} + +/** The object the class fills with forwarders to the statics the facets read, and its type. */ +const STATICS_NAME = `${CLASS_NAME}Statics`; +const STATICS_HOST_NAME = `${CLASS_NAME}StaticsHost`; +/** How a routed facet module names the class as a type: `SqlDriver` there is `SqlDriverStatics`. */ +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. */`; + +/** + * The statics block `sql-driver.ts` carries for the routed facets: the type and + * the object ahead of the class, and the static block at the end of the class + * that fills the object. `kinds` maps each routed static to `method` (a + * forwarder that calls it on the class) or `getter` (read when read). Pure: + * `verify` rebuilds both texts from the base class and compares them. + */ +function staticsTexts(kinds) { + const members = [...kinds.keys()].sort(); + const ahead = [ + `// ${MARK} The statics of \`${CLASS_NAME}\` its facet modules read, private ones included, as`, + `// the class declares them (an indexed access on the class). The static block at the end of`, + `// the class fills \`${STATICS_NAME}\` with forwarders: a method is called on the class at`, + '// call time, a field or accessor is read when the body reads it. A facet module imports', + `// \`${STATICS_NAME}\` AS \`${CLASS_NAME}\`, so each moved body reads \`${CLASS_NAME}.x\` unedited;`, + '// `index.ts` re-exports neither, so neither reaches the package.', + `export interface ${STATICS_HOST_NAME} {`, + ...members.map((n) => ` readonly ${n}: (typeof ${CLASS_NAME})['${n}'];`), + '}', + `export const ${STATICS_NAME} = {} as ${STATICS_HOST_NAME};`, + ].join('\n'); + const block = [ + ` // ${MARK} Fills \`${STATICS_NAME}\` (declared above the class) for the facet modules.`, + ' static {', + ` Object.defineProperties(${STATICS_NAME}, {`, + ...members.map((n) => (kinds.get(n) === 'method' + ? ` ${n}: { value: (...args: Parameters) => ${CLASS_NAME}.${n}(...args) },` + : ` ${n}: { get: () => ${CLASS_NAME}.${n} },`)), + ' });', + ' }', + ].join('\n'); + return { ahead, block }; +} + +/** + * The facet hosts (#30105): `HOSTS_MODULE`'s text, and the static block at the + * end of the class that installs one host factory per facet into it. `facets` + * lists each facet module whose bodies read the class, with `members` (member + * -> `method` | `getter`, the union over its bodies) and `written` (the fields + * a body assigns). The host is the module's whole `
Host`, so every + * stub of the facet hands its body the same object, which carries every + * member any body of the facet reads (a `Pick` of it is what each body's + * `this` names). Each member forwards as the per-call literal did: a method + * as a closure that calls it on the driver at call time, a field or accessor + * as a getter read when the body reads it, a written field with a setter that + * assigns it on the driver. So a spy or subclass override installed after the + * host was built is still the one reached, and the only thing a body can tell + * apart is that two calls see the same host object, which no moved body can + * observe: a body that reads `this` bare stays on the class (`bare-this`). + * Pure: `verify` rebuilds both texts from the head's host interfaces and the + * base class, and compares them. + */ +function hostsTexts(facets) { + const sorted = [...facets].sort((a, b) => (a.moduleFile < b.moduleFile ? -1 : a.moduleFile > b.moduleFile ? 1 : 0)); + const module = [ + '// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.', + '', + '/**', + ` * ${MARK} The host of each facet of \`${CLASS_NAME}\`, built ONCE per driver (#30105):`, + ` * what every stub of a facet hands that facet's bodies as \`this\`, in place of`, + ' * a host literal built on every call. Each `FacetHost` below is filled by the', + ` * factory \`${CLASS_NAME}\`'s static block installs, the one place that can read`, + ' * the class\'s private members; the host is built on the first call that needs', + ' * it and kept for the driver\'s life. A method goes as a closure that calls it', + ' * on the driver at call time, a field or accessor as a getter (and a setter', + ' * when a body assigns it), so a body reads and writes the driver exactly as', + ' * the per-call literal let it.', + ' *', + ` * Written by \`${SCRIPT}\`; it imports types only, so it forms no`, + ' * evaluation cycle and is evaluated before the class whichever module loads', + ' * first. `index.ts` re-exports nothing from this module.', + ' */', + '', + `import type { ${CLASS_NAME} } from './sql-driver.js';`, + ...sorted.map((f) => `import type { ${hostNameOf(f.moduleFile)} } from './${f.moduleFile.replace(/\.ts$/, '.js')}';`), + '', + `/** One facet's host for each driver: built on first use, kept for the driver's life. */`, + `export class ${HOSTS_CLASS} {`, + ` private readonly hosts = new WeakMap<${CLASS_NAME}, H>();`, + ` private build: ((driver: ${CLASS_NAME}) => H) | undefined;`, + '', + ' constructor(private readonly facet: string) {}', + '', + ` /** Called once, from \`${CLASS_NAME}\`'s static block. */`, + ` install(build: (driver: ${CLASS_NAME}) => H): void {`, + ' this.build = build;', + ' }', + '', + ' /** The host of `driver`, built on first use. */', + ` of(driver: ${CLASS_NAME}): H {`, + ' let host = this.hosts.get(driver);', + ' if (host === undefined) {', + ' if (this.build === undefined) {', + ` throw new Error(\`\${this.facet}: ${CLASS_NAME} has not installed its host factory\`);`, + ' }', + ' host = this.build(driver);', + ' this.hosts.set(driver, host);', + ' }', + ' return host;', + ' }', + '}', + '', + ...sorted.map((f) => `export const ${hostVarOf(f.moduleFile)} = new ${HOSTS_CLASS}<${hostNameOf(f.moduleFile)}>('${facetNameOf(f.moduleFile)}');`), + '', + ].join('\n'); + const block = [ + ` // ${MARK} Installs each facet's host factory (\`${HOSTS_MODULE}\`): a static block adds`, + ' // no member to the class, so the published `.d.ts` does not move.', + ' static {', + ...sorted.flatMap((f) => [ + ` ${hostVarOf(f.moduleFile)}.install((driver) => ({`, + ...[...f.members.keys()].sort().flatMap((member) => { + if (f.members.get(member) === 'getter') { + return [` get ${member}() { return driver.${member}; },`, + ...(f.written.has(member) ? [` set ${member}(v) { driver.${member} = v; },`] : [])]; + } + if (BOUND_HOST_METHODS.has(member)) return [` get ${member}() { return driver.${member}.bind(driver); },`]; + return [` ${member}: (...a) => driver.${member}(...a),`]; + }), + ' }));', + ]), + ' }', + ].join('\n'); + return { module, block }; +} + /** Is `node` inside a JSDoc comment? A `{@link X}` there is prose, not a use. */ function inJsDoc(tm, node) { for (let n = node.compilerNode; n; n = n.parent) { @@ -691,7 +945,7 @@ function declaredNames(tm, st) { /** * Lever (b): read every top-level statement between the last import and the * class, and decide which move whole into the leaf. A mover never names the - * class, a `sql-driver-*` module's import, or a statement that stays — so the + * class, an `sql-driver-*` module's import, or a statement that stays — so the * leaf it lands in never imports `sql-driver.ts` back. Pure. */ function planTopLevel(tm, ctx) { @@ -741,7 +995,7 @@ function planTopLevel(tm, ctx) { if (ts.isImportSpecifier(decl) || ts.isImportClause(decl) || ts.isNamespaceImport(decl)) { const local = node.getText(); const binding = ctx.scope.bindings.get(local); - if (binding && /^\.\/sql-driver/.test(binding.specifier)) add('seam-import', local); + if (binding && (SEAM_SPECIFIER.test(binding.specifier) || binding.specifier === './sql-driver.js')) add('seam-import', local); const prev = record.imports.get(local); record.imports.set(local, { value: Boolean(prev && prev.value) || !typePos }); continue; @@ -810,30 +1064,36 @@ function hostUsesOfMovers(tm, ctx, top, methodRecords) { } /** - * Read `text` (a `sql-driver.ts`) and decide, per method, where its body goes, + * Read `text` (an `sql-driver.ts`) and decide, per method, where its body goes, * and per top-level declaration ahead of the class, whether it moves to the * leaf. Pure: the same text gives the same plan. */ function plan(tm, text, { - sections = SECTION_MODULES, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES, + sections = SECTION_MODULES, head = CLASS_HEAD_SECTION, pins = PATH_READER_PINS, needles = PATH_READER_NEEDLES, classNeedles = CLASS_PATH_READER_NEEDLES, } = {}) { const sf = parse(tm, HOST_PATH, text); const cls = sf.getClass(CLASS_NAME); if (!cls) refuse(`no class ${CLASS_NAME} in ${HOST_BASENAME}`); - const lines = text.split('\n'); - const banners = readBanners(lines, cls.getStartLineNumber(), cls.getEndLineNumber()); - const unknown = banners.filter((b) => !Object.prototype.hasOwnProperty.call(sections, b.title)); - if (unknown.length > 0) { - refuse(`banner(s) the section table does not name: ${unknown.map((b) => `"${b.title}" (line ${b.line})`).join(', ')}. ` + - 'Add each to SECTION_MODULES (a module name, or null to keep the section on the class).'); - } + const sectionOf = readSections(text, cls, sections, head); + // The class's statics: scope, and what a forwarder in `SqlDriverStatics` + // would be (a method forwards as a function; a field or accessor as a getter). const statics = new Map(); for (const m of cls.getMembers()) { if (typeof m.isStatic === 'function' && m.isStatic() && typeof m.getName === 'function') { - statics.set(m.getName(), typeof m.getScope === 'function' ? m.getScope() : 'public'); + const { Node } = tm; + const prev = statics.get(m.getName()); + const kind = Node.isMethodDeclaration(m) ? 'method' : Node.isSetAccessorDeclaration(m) ? (prev ? prev.kind : 'setter') + : Node.isGetAccessorDeclaration(m) ? 'getter' : 'field'; + statics.set(m.getName(), { + scope: typeof m.getScope === 'function' ? m.getScope() : 'public', kind, + overloaded: Node.isMethodDeclaration(m) && m.getOverloads().length > 0, + }); } } const ctx = { text, sf, cls, members: instanceMembers(tm, cls), scope: moduleScope(tm, sf), statics, pins, needles, classNeedles }; + 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`); + } const methodNames = new Set(cls.getMethods().map((m) => m.getName())); const stalePins = Object.keys(pins).filter((n) => !methodNames.has(n) && !ctx.scope.bindings.has(n)); if (stalePins.length > 0) refuse(`PATH_READER_PINS names what ${HOST_BASENAME} no longer declares: ${stalePins.join(', ')}`); @@ -845,47 +1105,67 @@ function plan(tm, text, { const top = planTopLevel(tm, ctx); const records = []; - const seen = new Set(); for (const m of cls.getMethods()) { const startLine = m.getStartLineNumber(true); - let banner = null; - for (const b of banners) if (b.line < startLine) banner = b; + const section = sectionOf(m); const read = readMethod(tm, ctx, m); const name = m.getName(); - const firstOfName = !seen.has(name); - seen.add(name); - if (!banner) read.reasons.unshift('no-section'); - else if (sections[banner.title] === null) read.reasons.unshift('section-kept'); + if (!section) read.reasons.unshift('no-section'); + else if (section[1] === null) read.reasons.unshift('section-kept'); const lineCount = m.getEndLineNumber() - startLine + 1; const body = m.getBody(); const bodyLines = body ? body.getEndLineNumber() - body.getStartLineNumber() + 1 : 0; + // Docblock ownership (ruling 6624345766 item 1): a PRIVATE method's + // docblock moves with its body; a public or protected one stays on the + // facade, where the published surface prints it. + const docStart = m.getStart(true); + const doc = m.getScope() === 'private' && docStart < m.getStart() ? text.slice(docStart, m.getStart()) : null; const record = { - name, node: m, section: banner ? banner.title : null, module: banner ? sections[banner.title] : null, - startLine, lineCount, bodyLines, firstOfName, ...read, + name, node: m, section: section ? section[0] : null, module: section ? section[1] : null, + startLine, lineCount, bodyLines, doc, docLines: doc ? doc.split('\n').length - 1 : 0, ...read, }; - if (record.reasons.length === 0) { - record.stubLines = stubBody(record, record.module, ' ').split('\n').length; - if (record.stubLines >= bodyLines) record.reasons.push('no-yield'); + if (section && section[1]) { + for (const p of m.getParameters()) { + if (p.getName() === facetNameOf(section[1]) || p.getName() === hostVarOf(section[1])) read.reasons.push(`parameter-shadows: ${p.getName()}`); + } } + if (record.reasons.length === 0) record.stubLines = stubBody(record, record.module, ' ').split('\n').length; records.push(record); } - // A function name its module's imports already use: drop it, recompute, until stable. + // The private-static-through-host rule, per facet module: once one mover + // reads the statics through `SqlDriverStatics` (imported AS `SqlDriver`), + // every mover in that module does, so what the route cannot carry keeps a + // mover on the class. A router itself never carries a blocker (readMethod + // already kept it), so the set of routed modules is stable. for (let changed = true; changed;) { changed = false; - const byModule = groupMovers(records); - for (const [moduleFile, movers] of byModule) { - const taken = new Set([CLASS_NAME, hostNameOf(moduleFile), 'ThisParameterType', 'Pick']); - for (const r of movers) { - for (const local of r.imports.keys()) taken.add(local); - for (const local of r.moduleRefs.keys()) taken.add(local); - } + for (const movers of groupMovers(records).values()) { + if (!movers.some((r) => r.readsNonPublicStatic)) continue; for (const r of movers) { - if (taken.has(r.name)) { r.reasons.push('name-collision'); changed = true; } + const extra = [...r.routeBlockers, ...(r.classOther ? ['static-route-conflict'] : [])].filter((t) => !r.reasons.includes(t)); + if (extra.length > 0) { r.reasons.push(...extra); changed = true; } } } } const modules = groupMovers(records); + const routedModules = new Set(); + const routedStatics = new Map(); + for (const [moduleFile, movers] of modules) { + if (!movers.some((r) => r.readsNonPublicStatic)) continue; + routedModules.add(moduleFile); + for (const r of movers) for (const [member, kind] of r.classStatics) routedStatics.set(member, kind); + } + for (const [moduleFile, movers] of modules) { + const facet = facetNameOf(moduleFile); + for (const r of movers) { + for (const local of [...r.imports.keys(), ...r.moduleRefs.keys()]) { + if (local === facet || local === hostNameOf(moduleFile) || local === CLASS_TYPE_ALIAS) { + refuse(`${moduleFile}: ${r.name} reads ${local}, a name this script generates in that module`); + } + } + } + } const hostUses = hostUsesOfMovers(tm, ctx, top, records); // What each side imports across the cut: the section modules read moved @@ -908,10 +1188,10 @@ function plan(tm, text, { if (r.exported) continue; for (const n of r.names) if (hostUses.has(n) || sectionLeafNames.has(n)) leafExports.push(n); } - return { sf, cls, ctx, banners, records, modules, top, hostUses, leafExports, hostExportsForSections }; + return { sf, cls, ctx, records, modules, top, hostUses, leafExports, hostExportsForSections, routedModules, routedStatics }; } -/** Movers grouped by module, modules in the order their first banner appears. */ +/** Movers grouped by module, modules in the order their first member appears. */ function groupMovers(records) { const byModule = new Map(); for (const r of records) { @@ -924,7 +1204,6 @@ function groupMovers(records) { // ── Generation ─────────────────────────────────────────────────────────────── -const readerName = (member) => `read${member[0].toUpperCase()}${member.slice(1)}`; const byName = ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0); const leafSpecifier = () => `./${LEAF_MODULE.replace(/\.ts$/, '.js')}`; @@ -963,7 +1242,7 @@ function importPair(specifier, uses) { * sql-driver.ts's import order; then the moved top-level names, from the leaf; * then what stayed in sql-driver.ts (lever (a)), including the class. */ -function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), leafNames = new Map() } = {}) { +function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), leafNames = new Map(), routed = false } = {}) { const bySpecifier = new Map(); for (const r of users) { for (const [local, use] of r.imports) { @@ -998,7 +1277,13 @@ function importLines(scope, users, { needsHost = false, moduleRefs = new Map(), const fromLeaf = new Map(); const fromHost = new Map(); for (const [n, use] of moduleRefs) (leafNames.has(n) ? fromLeaf : fromHost).set(n, use); - if (needsHost && !fromHost.has(CLASS_NAME)) fromHost.set(CLASS_NAME, { value: false }); + if (routed) { + // The private-static-through-host rule: `SqlDriver.x` in a moved body reads + // the forwarder object; the host interface names the class by an alias. + fromHost.delete(CLASS_NAME); + fromHost.set(`${STATICS_NAME} as ${CLASS_NAME}`, { value: true }); + if (needsHost) fromHost.set(`${CLASS_NAME} as ${CLASS_TYPE_ALIAS}`, { value: false }); + } else if (needsHost && !fromHost.has(CLASS_NAME)) fromHost.set(CLASS_NAME, { value: false }); out.push(...importPair(leafSpecifier(), fromLeaf)); out.push(...importPair('./sql-driver.js', fromHost)); return out; @@ -1024,10 +1309,14 @@ function forwardList(m) { return m.getParameters().map((p) => (p.isRestParameter() ? `...${p.getName()}` : p.getName())); } -function moduleText(moduleFile, title, movers, scope, leafNames) { +function moduleText(moduleFile, title, movers, scope, leafNames, routed = false) { const hostName = hostNameOf(moduleFile); + const facet = facetNameOf(moduleFile); + const classType = routed ? CLASS_TYPE_ALIAS : CLASS_NAME; const hostMembers = new Map(); + const written = new Set(); for (const r of movers) for (const [member, kind] of r.host) hostMembers.set(member, kind); + for (const r of movers) for (const member of r.writes) written.add(member); const needsHost = hostMembers.size > 0; const moduleRefs = new Map(); for (const r of movers) { @@ -1043,25 +1332,33 @@ function moduleText(moduleFile, title, movers, scope, leafNames) { out.push(''); out.push('/**'); out.push(...wrapComment([MARK, 'The', `"${title}"`, 'section', 'of', '`SqlDriver`:', 'the', 'bodies', 'of', - ...names, 'moved', 'out', 'of', '`sql-driver.ts`', 'whole', 'by', '`scripts/codemod-split-sql-driver.mjs`.'])); + ...names, 'moved', 'out', 'of', '`sql-driver.ts`', 'whole', 'by', `\`${SCRIPT}\`.`])); out.push(' *'); - out.push(' * A MOVE, not a redesign. Each function below is the body of the `SqlDriver`'); + out.push(` * A MOVE, not a redesign. {@link ${facet}} is a stateless facet of \`SqlDriver\``); + out.push(' * (ruling 6624345766): each static method of it is the body of the `SqlDriver`'); out.push(' * method of the same name exactly as it stood: not re-indented, not'); - out.push(' * formatted, not edited. The method stays on the class with its docblock and'); - out.push(' * signature, so the published `dist/*.d.ts`, an instance spy and a subclass'); - out.push(' * override are what they were; the docblock there is the contract.'); + out.push(' * formatted, not edited. The method stays on the class as a one-call stub with'); + out.push(' * its signature, so the published surface, an instance spy and a subclass'); + out.push(' * override are what they were. A public or protected method keeps its'); + out.push(' * docblock on the class, the contract; a private method\'s docblock moved here'); + out.push(' * with its body. The state stays on `SqlDriver`.'); out.push(' *'); if (needsHost) { - out.push(` * The bodies reach the class through {@link ${hostName}}: exactly the`); - out.push(' * `SqlDriver` members they read. Each method hands its body one literal of the'); - out.push(' * members that body reads, plus a `.call`: methods go as closures that call'); - out.push(' * them on the driver at call time (the builder factory as a bound reference),'); - out.push(' * so a subclass override or an instance spy is still the one reached; fields'); - out.push(' * and accessors go as GETTERS, read when the body reads them, as they were on'); - out.push(' * the class.'); + out.push(...wrapComment((`The bodies reach the class through {@link ${hostName}}: exactly the \`SqlDriver\` ` + + 'members they read, private ones included. Every stub hands its body, by a `.call`, the same host: ' + + `\`${hostVarOf(moduleFile)}\` in \`${HOSTS_MODULE}\`, 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.').split(' '))); } else { - out.push(' * No body reads a `SqlDriver` member, so each method calls its function'); - out.push(' * directly.'); + out.push(' * No body reads an `SqlDriver` member, so each stub calls its body directly.'); + } + if (routed) { + out.push(' *'); + out.push(` * \`SqlDriver\` here is \`${STATICS_NAME}\` (imported AS \`SqlDriver\`): forwarders to the`); + out.push(' * statics the bodies read, private ones included, which the class fills in a'); + out.push(` * static block; the class as a type is \`${CLASS_TYPE_ALIAS}\`.`); } out.push(' *'); if (readsHostValues) { @@ -1070,35 +1367,41 @@ function moduleText(moduleFile, title, movers, scope, leafNames) { out.push(' * cycle is never read at evaluation time. `index.ts` re-exports nothing from'); out.push(' * this module.'); } else { - out.push(' * The module imports `sql-driver.ts` for types only, so the two modules form'); - out.push(' * no evaluation cycle. `index.ts` re-exports nothing from this module.'); + out.push(' * The module imports `sql-driver.ts` for types only, so the two modules form no'); + out.push(' * evaluation cycle. `index.ts` re-exports nothing from this module.'); } out.push(' */'); out.push(''); - out.push(...importLines(scope, movers, { needsHost, moduleRefs, leafNames })); + out.push(...importLines(scope, movers, { needsHost, moduleRefs, leafNames, routed })); if (needsHost) { out.push(''); out.push('/**'); - out.push(' * The `SqlDriver` members the functions below read — every one of them, and'); - out.push(' * no other. Each is typed AS the class member (an indexed access on the'); - out.push(' * class), so a change to a member\'s signature on the class is a change here'); - out.push(' * too. `readonly` members are the getters.'); + out.push(` * The \`SqlDriver\` members the bodies of {@link ${facet}} read — every one of`); + out.push(' * them, and no other. Each is typed AS the class member (an indexed access on'); + out.push(' * the class), so a change to a member\'s signature on the class is a change'); + out.push(' * here too. `readonly` members are the getters; the others are fields a body'); + out.push(' * assigns.'); out.push(' */'); out.push(`export interface ${hostName} {`); for (const member of [...hostMembers.keys()].sort()) { - out.push(` ${hostMembers.get(member) === 'getter' ? 'readonly ' : ''}${member}: SqlDriver['${member}'];`); + const ro = hostMembers.get(member) === 'getter' && !written.has(member); + out.push(` ${ro ? 'readonly ' : ''}${member}: ${classType}['${member}'];`); } out.push('}'); } - for (const r of movers) { + out.push(''); + out.push(`/** The bodies of the "${title}" section of \`SqlDriver\`: a stateless facet, called by the class's stubs. */`); + out.push(`export class ${facet} {`); + 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()}` : ''; - out.push(''); - out.push(`/** The body of \`SqlDriver#${r.name}\`; the method's docblock there is its contract. */`); - out.push(`export ${m.isAsync() ? 'async ' : ''}function ${r.name}(${params.join(', ')})${ret} ${m.getBody().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()}`); + }); + out.push('}'); out.push(''); return out.join('\n'); } @@ -1111,14 +1414,14 @@ function moduleText(moduleFile, title, movers, scope, leafNames) { */ function leafText(scope, top, leafExports) { // The header is `//` lines, not a `/** */` docblock: the bundled `.d.ts` - // keeps a JSDoc block that leads a module's first statement, and this module - // is bundled FIRST, so a docblock here would be published text the base did - // not carry (measured: 14 added lines in dist/index.d.ts). + // keeps a JSDoc block that leads a module's first statement, and a leaf + // bundled first would publish text the base did not carry (the pilot's + // measurement: 14 added lines in dist/index.d.ts). const out = []; out.push('// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.'); out.push(''); out.push(...wrapComment([MARK, 'The', 'top-level', 'declarations', 'ahead', 'of', '`SqlDriver`', 'in', '`sql-driver.ts`', - 'that', 'moved', 'out', 'whole', 'by', '`scripts/codemod-split-sql-driver.mjs`', '(lever', '(b)', 'of', 'ruling', + 'that', 'moved', 'out', 'whole', 'by', `\`${SCRIPT}\``, '(lever', '(b)', 'of', 'ruling', '6591753301).']).map((l) => l.replace(/^ \*/, '//'))); out.push('//'); out.push('// A MOVE, not a redesign: each declaration below is its `sql-driver.ts` text'); @@ -1138,39 +1441,56 @@ function leafText(scope, top, leafExports) { return `${text}\n`; } -function stubBody(r, moduleFile, closeIndent) { +const DOC_MOVED = 'its docblock with it'; + +/** + * 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 + * one object per driver `HOSTS_MODULE` keeps (#30105), not a literal built per + * call. `node` is the method (base or head: the stub keeps its signature). + * Pure: `verify` rebuilds every stub with it and compares. + */ +function stubBody({ node, name, usesHost, docMoved }, moduleFile, closeIndent) { const ind = `${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 fn = typeArgs.length > 0 ? `(${facetNameOf(moduleFile)}.${name}<${typeArgs.join(', ')}>)` : `${facetNameOf(moduleFile)}.${name}`; const out = ['{']; - const args = forwardList(r.node).join(', '); - if (r.host.size === 0) { - out.push(`${ind}// ${MARK} The body moved to \`${moduleFile}\`.`); - out.push(`${ind}return ${r.name}(${args});`); + const args = forwardList(node).join(', '); + const doc = docMoved ? `, ${DOC_MOVED}` : ''; + if (!usesHost) { + out.push(`${ind}// ${MARK} The body moved to \`${moduleFile}\`${doc}.`); + out.push(`${ind}return ${fn}(${args});`); } else { - out.push(`${ind}// ${MARK} The body moved to \`${moduleFile}\` (see \`${hostName}\`).`); - const members = [...r.host.keys()].sort(); - for (const member of members) { - if (r.host.get(member) === 'getter') out.push(`${ind}const ${readerName(member)} = () => this.${member};`); - } - out.push(`${ind}const host: ThisParameterType = {`); - for (const member of members) { - if (r.host.get(member) === 'getter') out.push(`${ind} get ${member}() { return ${readerName(member)}(); },`); - else if (BOUND_HOST_METHODS.has(member)) out.push(`${ind} ${member}: this.${member}.bind(this),`); - else out.push(`${ind} ${member}: (...a) => this.${member}(...a),`); - } - out.push(`${ind}};`); - out.push(`${ind}return ${r.name}.call(host${args ? `, ${args}` : ''});`); + out.push(`${ind}// ${MARK} The body moved to \`${moduleFile}\`${doc} (see \`${hostName}\`).`); + out.push(`${ind}return ${fn}.call(${hostVarOf(moduleFile)}.of(this)${args ? `, ${args}` : ''});`); } out.push(`${closeIndent}}`); return out.join('\n'); } +/** The facets whose bodies read the class, as `hostsTexts` takes them, from the movers of each module. */ +function facetsOf(modules) { + const facets = []; + for (const [moduleFile, movers] of modules) { + const members = new Map(); + const written = new Set(); + for (const r of movers) for (const [member, kind] of r.host) members.set(member, kind); + for (const r of movers) for (const member of r.writes) written.add(member); + if (members.size > 0) facets.push({ moduleFile, members, written }); + } + return facets; +} + /** - * Apply a plan to `text`. Returns `{ host, modules }`: the new `sql-driver.ts` - * and a Map of module basename -> text (the leaf among them). Pure. + * Apply a plan to `text`. Returns `{ host, modules }`: the new + * `sql-driver.ts` and a Map of module basename -> text (the leaf and the host + * module among them). Pure. */ function generate(tm, text, planned) { - const { sf, ctx, modules, top, hostUses, leafExports, hostExportsForSections } = planned; + const { sf, ctx, modules, top, hostUses, leafExports, hostExportsForSections, routedModules, routedStatics } = planned; const edits = []; const importBlock = []; if (top.movers.length > 0) { @@ -1195,17 +1515,48 @@ function generate(tm, text, planned) { const anyHost = movers.some((r) => r.host.size > 0); importBlock.push(`\n// ${MARK} ${title}: the methods of the same names below hand the bodies` + `${anyHost ? ` what they read (\`${hostName}\`)` : ' their arguments'}.`); - importBlock.push(`\nimport { ${movers.map((r) => r.name).sort().join(', ')} } from './${moduleFile.replace(/\.ts$/, '.js')}';`); + importBlock.push(`\nimport { ${facetNameOf(moduleFile)} } from './${moduleFile.replace(/\.ts$/, '.js')}';`); for (const r of movers) { const body = r.node.getBody(); const lineStart = text.lastIndexOf('\n', body.getEnd() - 1) + 1; const closeIndent = text.slice(lineStart, body.getEnd() - 1); if (!/^ *$/.test(closeIndent)) refuse(`${r.name}: the body's closing brace does not open its own line`); - edits.push({ start: body.getStart(), end: body.getEnd(), text: stubBody(r, moduleFile, closeIndent) }); + if (r.host.size > 0 && r.node.getParameters().some((p) => p.getName() === hostVarOf(moduleFile))) { + refuse(`${r.name}: a parameter is named ${hostVarOf(moduleFile)}, which its stub reads as the facet's host`); + } + const stub = stubBody({ node: r.node, name: r.name, usesHost: r.host.size > 0, docMoved: Boolean(r.doc) }, moduleFile, closeIndent); + edits.push({ start: body.getStart(), end: body.getEnd(), text: stub }); 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: '' }); } } + // 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). + const tail = []; + if (routedStatics.size > 0) { + // The private-static-through-host rule: the forwarder object ahead of the + // class (ahead of its leading comments), the static block that fills it + // after the class's last member. + const { ahead, block } = staticsTexts(routedStatics); + edits.push({ start: ctx.cls.getFullStart(), end: ctx.cls.getFullStart(), text: `\n\n${ahead}` }); + tail.push(`\n\n${block}`); + } + const facets = facetsOf(modules); + const hosts = facets.length > 0 ? hostsTexts(facets) : null; + if (hosts) { + for (const f of facets) { + if (ctx.scope.bindings.has(hostVarOf(f.moduleFile))) refuse(`sql-driver.ts already binds ${hostVarOf(f.moduleFile)}, the name of ${f.moduleFile}'s host`); + } + tail.push(`\n\n${hosts.block}`); + importBlock.push(`\n// ${MARK} The facets' hosts, one per driver, which the static block at the end of the class builds.`); + importBlock.push(`\n${namedStatement('import', facets.map((f) => hostVarOf(f.moduleFile)).sort(), ` from './${HOSTS_MODULE.replace(/\.ts$/, '.js')}';`)}`); + } + if (tail.length > 0) { + const last = ctx.cls.getMembers().at(-1); + edits.push({ start: last.getEnd(), end: last.getEnd(), text: tail.join('') }); + } if (hostExportsForSections.size > 0) { const spelled = [...hostExportsForSections.keys()].sort().map((n) => { const st = ctx.sf.getStatements().find((s) => (declaredNames(tm, s) ?? []).includes(n)); @@ -1229,8 +1580,9 @@ function generate(tm, text, planned) { const leafNames = top.moverNames; if (top.movers.length > 0) moduleTexts.set(LEAF_MODULE, leafText(ctx.scope, top, leafExports)); for (const [moduleFile, movers] of modules) { - moduleTexts.set(moduleFile, moduleText(moduleFile, movers[0].section, movers, ctx.scope, leafNames)); + moduleTexts.set(moduleFile, moduleText(moduleFile, movers[0].section, movers, ctx.scope, leafNames, routedModules.has(moduleFile))); } + if (hosts) moduleTexts.set(HOSTS_MODULE, hosts.module); return { host: out, modules: moduleTexts }; } @@ -1244,12 +1596,15 @@ const sha256 = (text) => createHash('sha256').update(text).digest('hex'); * declaration is its base statement's text, and undoing the split rebuilds the * base `sql-driver.ts` byte-for-byte. * - * Undoing is: put each body back on its stub (and `async` back), drop the - * generated import / export statements, then splice the leaf's statements back - * between the statements that stayed — the interleaving read off the base by - * NAME only, so every byte of the result comes from the head or the leaf. + * Undoing is: put each body back on its stub (and `async` back, and a moved + * docblock back above it), drop the generated statics forwarders and the two + * generated static blocks once each is proved to be exactly what the generator + * writes for what it names, drop the generated import / export statements, + * then splice the leaf's statements back between the statements that stayed — + * the interleaving read off the base by NAME only, so every byte of the result + * comes from the head, a facet module or the leaf. * - * Returns `{ failures, moved, movedTop, linesBefore, linesAfter }`; no failure = proved. + * Returns `{ failures, moved, movedTop, linesBefore, linesAfter, routedStatics }`; no failure = proved. */ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {}) { const { Node } = tm; @@ -1262,11 +1617,14 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} const headCls = headSf.getClass(CLASS_NAME); const none = { failures: [`no class ${CLASS_NAME} at base or head`], moved, movedTop, linesBefore: 0, linesAfter: 0 }; if (!baseCls || !headCls) return none; + const target = baseText; const modules = new Map(); let leaf = null; + let hostsOnDisk = null; for (const [file, text] of moduleTexts) { if (!text.includes(MARK)) continue; + if (file === HOSTS_MODULE) { hostsOnDisk = text; continue; } const sf = parse(tm, join(SRC_DIR, file), text); if (file === LEAF_MODULE) { const statements = []; @@ -1276,7 +1634,7 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} const headerEnd = text.indexOf('\n\n', text.indexOf(MARK)) + 1; stmts.forEach((st, i) => { if (Node.isImportDeclaration(st)) { - if (/^\.\/sql-driver/.test(st.getModuleSpecifierValue())) { + if (st.getModuleSpecifierValue() === './sql-driver.js' || SEAM_SPECIFIER.test(st.getModuleSpecifierValue())) { failures.push(`${file}: imports ${st.getModuleSpecifierValue()} — a leaf never imports sql-driver.ts or a sql-driver-* module`); } return; @@ -1288,32 +1646,52 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} }); leaf = { file, sf, statements }; if (readSibling) { - // The leaf's relative imports, followed on disk: none may reach sql-driver.ts. + // The leaf's relative VALUE imports, followed on disk: none may reach + // sql-driver.ts or a sql-driver-* module. A type-only import is erased, so it + // forms no evaluation cycle and is not followed. const seen = new Set(); - const queue = sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue()).filter((s) => s.startsWith('./')); + const queue = valueSpecifiers(text).map((spec) => [file, spec]); while (queue.length > 0) { - const spec = queue.shift(); - if (seen.has(spec)) continue; - seen.add(spec); - if (/^\.\/sql-driver(\.js|-)/.test(spec)) { failures.push(`${file}: reaches ${spec} through its imports — not a leaf`); continue; } - const sibling = readSibling(spec.replace(/^\.\//, '').replace(/\.js$/, '.ts')); + const [from, spec] = queue.shift(); + let rel = relative(SRC_DIR, resolve(dirname(join(SRC_DIR, from)), spec.replace(/\.js$/, '.ts'))); + if (seen.has(rel)) continue; + seen.add(rel); + if (rel === HOST_BASENAME || /^sql-driver-[a-z0-9-]+\.ts$/.test(rel)) { + failures.push(`${file}: reaches ${rel} through its value imports (from ${from}) — not a leaf`); + continue; + } + let sibling = readSibling(rel); + if (sibling === null) { + const index = rel.replace(/\.ts$/, '/index.ts'); + sibling = readSibling(index); + if (sibling !== null) rel = index; + } if (sibling === null) continue; - for (const m of sibling.matchAll(/^\s*(?:import|export)\s[^;]*?from\s+'(\.\/[^']+)'/gm)) queue.push(m[1]); + for (const next of valueSpecifiers(sibling)) queue.push([rel, next]); } } continue; } + // A facet module: imports, the host interface, and ONE class, the facet, + // whose every member is a static method that is a moved body. const fns = new Map(); + const facet = facetNameOf(file); for (const st of sf.getStatements()) { - if (Node.isFunctionDeclaration(st)) fns.set(st.getName(), st); - else if (!Node.isImportDeclaration(st) && !Node.isInterfaceDeclaration(st)) { - failures.push(`${file}: a top-level statement that is neither an import, the host interface nor a moved function: ${st.getText().slice(0, 60)}`); + if (Node.isImportDeclaration(st)) continue; + if (Node.isInterfaceDeclaration(st) && st.getName() === hostNameOf(file)) continue; + if (Node.isClassDeclaration(st) && st.getName() === facet && !fns.size) { + for (const member of st.getMembers()) { + if (Node.isMethodDeclaration(member) && member.isStatic() && member.getBody() && !fns.has(member.getName())) fns.set(member.getName(), member); + else failures.push(`${file}: ${facet} has a member that is not a moved body: ${member.getText().slice(0, 60)}`); + } + continue; } + failures.push(`${file}: a top-level statement that is neither an import, the host interface nor the facet class ${facet}: ${st.getText().slice(0, 60)}`); } - modules.set(file, { sf, fns, claimed: new Set() }); + modules.set(file, { sf, text, fns, claimed: new Set() }); } - const marker = new RegExp(`^\\{\\s*// ${MARK.replace(/[[\]]/g, '\\$&')} The body moved to \`(sql-driver-[a-z0-9-]+\\.ts)\``); + 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()) { const body = m.getBody(); @@ -1325,7 +1703,17 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} const fn = mod && mod.fns.get(name); if (!fn) { failures.push(`${name}: its stub names ${hit[1]}, which has no function ${name}`); continue; } mod.claimed.add(name); - const base = baseCls.getMethod(name); + // The stub itself is exactly what `stubBody` writes for this method: one + // call of the facet member of the same name, with the facet's own host + // when the body reads the class (#30105). + { + const lineStart = headText.lastIndexOf('\n', body.getEnd() - 1) + 1; + const closeIndent = headText.slice(lineStart, body.getEnd() - 1); + const usesHost = fn.getParameters().some((p) => p.getName() === 'this'); + 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()); 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()); @@ -1334,22 +1722,101 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} params: JSON.stringify(fnParams) === JSON.stringify(baseParams), returnType: (fn.getReturnTypeNode()?.getText() ?? null) === (base.getReturnTypeNode()?.getText() ?? null), async: fn.isAsync() === base.isAsync(), - typeParameters: fn.getTypeParameters().length === 0 && base.getTypeParameters().length === 0, + typeParameters: JSON.stringify(fn.getTypeParameters().map((t) => t.getText())) === JSON.stringify(base.getTypeParameters().map((t) => t.getText())), + static: m.isStatic() === base.isStatic(), }; + // Docblock ownership: the facet member's leading text is the base + // method's docblock (the stub says so, and only a private method's), else + // exactly the placeholder; the undo puts a moved docblock back. + const lead = mod.text.slice(fn.getStart(true), fn.getStart()); + const docMoved = Boolean(hit[2]); + if (docMoved) { + if (m.getStart(true) !== m.getStart()) failures.push(`${name}: its stub says the docblock moved, but the class still carries one`); + if (m.getScope() !== 'private') failures.push(`${name}: a ${m.getScope()} method's docblock moved off the class (only a private one may)`); + edits.push({ start: m.getStart(), end: m.getStart(), text: lead }); + } else if (lead !== `${placeholderDoc(name)}\n `) { + failures.push(`${name}: the facet member's leading text is neither its moved docblock nor the placeholder`); + } for (const [what, ok] of Object.entries(same)) if (!ok) failures.push(`${name}: the generated function's ${what} is not the base method's`); moved.push({ name, module: hit[1], bodySha: sha256(fn.getBody().getText()), baseBodySha: sha256(base.getBody().getText()), - lines: base.getBody().getEndLineNumber() - base.getBody().getStartLineNumber() + 1, ...same, + 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 ' }); } + + // The statics route: the forwarder type and object ahead of the class and + // the static block that fills them must be exactly what `staticsTexts` + // writes for the statics the type names, each forwarded as the BASE class + // declares it (a method called, anything else read). Then the undo drops + // them. + const staticsHost = headSf.getInterface(STATICS_HOST_NAME); + const staticsVar = headSf.getVariableStatement(STATICS_NAME); + const markedBlocks = headCls.getMembers().filter((x) => Node.isClassStaticBlockDeclaration(x) && x.getFullText().includes(MARK)); + const hostsBlocks = markedBlocks.filter((x) => x.getFullText().includes(`// ${MARK} Installs`)); + const staticBlocks = markedBlocks.filter((x) => !hostsBlocks.includes(x)); + if (staticsHost || staticsVar || staticBlocks.length > 0) { + const kinds = new Map(); + for (const member of staticsHost ? staticsHost.getProperties() : []) { + const n = member.getName(); + const declared = baseCls.getMembers().filter((x) => typeof x.isStatic === 'function' && x.isStatic() && typeof x.getName === 'function' && x.getName() === n); + if (declared.length === 0) failures.push(`${STATICS_HOST_NAME}.${n}: the base class declares no static of that name`); + kinds.set(n, declared.some((x) => Node.isMethodDeclaration(x)) ? 'method' : 'getter'); + } + const { ahead, block } = staticsTexts(kinds); + const aheadAt = staticsHost ? headText.lastIndexOf(`// ${MARK} The statics of`, staticsHost.getStart()) : -1; + const aheadEnd = staticsVar ? staticsVar.getEnd() : -1; + if (!staticsHost || !staticsVar || aheadAt < 0 || headText.slice(aheadAt, aheadEnd) !== ahead) { + failures.push(`${STATICS_HOST_NAME} / ${STATICS_NAME} are not the generated forwarder declarations for the statics they name`); + } else { + edits.push({ start: headText.lastIndexOf('\n\n', aheadAt), end: aheadEnd, text: '' }); + } + const blockAt = staticBlocks.length === 1 ? headText.lastIndexOf(` // ${MARK} Fills`, staticBlocks[0].getStart()) : -1; + if (staticBlocks.length !== 1 || blockAt < 0 || headText.slice(blockAt, staticBlocks[0].getEnd()) !== block) { + failures.push(`the class's static block is not the generated filler of ${STATICS_NAME} for the statics it names`); + } else { + edits.push({ start: headText.lastIndexOf('\n\n', blockAt), end: staticBlocks[0].getEnd(), text: '' }); + } + } for (const [file, mod] of modules) { for (const name of mod.fns.keys()) if (!mod.claimed.has(name)) failures.push(`${file}: function ${name} has no stub on the class`); } + + // The facet hosts (#30105): `HOSTS_MODULE` and the static block that + // installs the factories must be exactly what `hostsTexts` writes for the + // facets on disk, each member forwarded as the BASE class declares it (a + // method as a closure, anything else as a getter, a member the interface + // does not mark `readonly` with a setter too). Then the undo drops both. + const baseMembers = instanceMembers(tm, baseCls); + const facets = []; + for (const [file, mod] of modules) { + const iface = mod.sf.getInterface(hostNameOf(file)); + if (!iface) continue; + const members = new Map(); + const written = new Set(); + for (const p of iface.getProperties()) { + const n = p.getName(); + const info = baseMembers.get(n); + if (!info) { failures.push(`${file}: ${hostNameOf(file)}.${n} names no instance member of the base class`); continue; } + members.set(n, info.kind === 'method' ? 'method' : 'getter'); + if (info.kind !== 'method' && !p.isReadonly()) written.add(n); + } + facets.push({ moduleFile: file, members, written }); + } + if (facets.length > 0 || hostsOnDisk !== null || hostsBlocks.length > 0) { + const want = hostsTexts(facets); + if (hostsOnDisk !== want.module) failures.push(`${HOSTS_MODULE} is not the generated host module for the facets on disk`); + const blockAt = hostsBlocks.length === 1 ? headText.lastIndexOf(` // ${MARK} Installs`, hostsBlocks[0].getStart()) : -1; + if (hostsBlocks.length !== 1 || blockAt < 0 || headText.slice(blockAt, hostsBlocks[0].getEnd()) !== want.block) { + failures.push('the class\'s host static block is not the generated installer of the facet hosts'); + } else { + edits.push({ start: headText.lastIndexOf('\n\n', blockAt), end: hostsBlocks[0].getEnd(), text: '' }); + } + } const generatedSpecifier = (spec) => { const hit = /^\.\/(sql-driver-[a-z0-9-]+)\.js$/.exec(spec ?? ''); - return Boolean(hit && (modules.has(`${hit[1]}.ts`) || (leaf && `${hit[1]}.ts` === leaf.file))); + return Boolean(hit && (modules.has(`${hit[1]}.ts`) || `${hit[1]}.ts` === HOSTS_MODULE || (leaf && `${hit[1]}.ts` === leaf.file))); }; for (const st of headSf.getStatements()) { const drop = (Node.isImportDeclaration(st) && generatedSpecifier(st.getModuleSpecifierValue())) || @@ -1367,7 +1834,7 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} const restSf = parse(tm, HOST_PATH, rebuilt); const rest = restSf.getStatements(); const queue = [...leaf.statements]; - const baseStmts = baseSf.getStatements(); + const baseStmts = parse(tm, HOST_PATH, target).getStatements(); let j = 0; let assembled = rest.length > 0 ? rebuilt.slice(0, rest[0].getFullStart()) : ''; for (const b of baseStmts) { @@ -1393,16 +1860,17 @@ function verify(tm, baseText, headText, moduleTexts, { readSibling = null } = {} rebuilt = assembled; } - if (rebuilt !== baseText) { + if (rebuilt !== target) { const a = rebuilt.split('\n'); - const b = baseText.split('\n'); + const b = target.split('\n'); let i = 0; while (i < Math.max(a.length, b.length) && a[i] === b[i]) i += 1; failures.push(`undoing the split does not rebuild the base sql-driver.ts: first difference at line ${i + 1}` + `\n rebuilt: ${JSON.stringify(a[i] ?? '')}\n base: ${JSON.stringify(b[i] ?? '')}`); } if (moved.length === 0 && movedTop.length === 0) failures.push('no moved method or declaration found: a proof over nothing is not a proof'); - return { failures, moved, movedTop, linesBefore: lineCount(baseText), linesAfter: lineCount(headText) }; + return { failures, moved, movedTop, linesBefore: lineCount(baseText), linesAfter: lineCount(headText), + routedStatics: staticsHost ? staticsHost.getProperties().length : 0 }; } /** Lines as `wc -l` counts them: newline characters. */ @@ -1410,132 +1878,13 @@ function lineCount(text) { return text.split('\n').length - 1; } -// ── Proof 2: the declaration-file bar (ruling 6348891160 item 1) ───────────── - -/** - * The ruling's one normalisation, verbatim: sort the members of each run of - * string-literal union members in place. Nothing else is normalised. - */ -const NORMALISER_ONE_LINER = - 'node -e \'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\\n]*"(?:\\s*\\|\\s*"[^"\\n]*")+/g,r=>r.split(/\\s*\\|\\s*/).sort().join(" | ")))\' FILE | sha256sum'; - -function normaliseUnionOrder(text) { - return text.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g, (r) => r.split(/\s*\|\s*/).sort().join(' | ')); -} - -/** Export names of a bundled `.d.ts`: every `export { … }` list, plus `export Name`. */ -function dtsExportNames(text) { - const names = new Set(); - for (const m of text.matchAll(/^export\s*(?:type\s*)?\{([^}]*)\}/gm)) { - for (const part of m[1].split(',')) { - const t = part.trim().replace(/^type\s+/, ''); - if (!t) continue; - const as = /\bas\s+([\w$]+)$/.exec(t); - names.add(as ? as[1] : t); - } - } - for (const m of text.matchAll(/^export\s+(?:declare\s+)?(?:abstract\s+)?(?:class|interface|type|function|const|let|var|enum|namespace)\s+([\w$]+)/gm)) names.add(m[1]); - return [...names].sort(); -} - -/** One positive control: the first `private` member made public. */ -function injectVisibilityChange(text) { - const at = text.search(/^ {4}private /m); - return at < 0 ? null : text.slice(0, at) + ' ' + text.slice(at + ' private '.length); -} - -/** - * The number of lines deleted plus lines inserted between two line arrays — - * what `diff` prints as `<` and `>` lines (Myers' O((N+M)D) edit distance, so - * a handful of changed lines in a 9,000-line file costs nothing). - */ -function lineEditDistance(a, b) { - const n = a.length; - const m = b.length; - const max = n + m; - const off = max + 1; - const v = new Int32Array(2 * max + 3); - for (let d = 0; d <= max; d += 1) { - for (let k = -d; k <= d; k += 2) { - let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) ? v[off + k + 1] : v[off + k - 1] + 1; - let y = x - k; - while (x < n && y < m && a[x] === b[y]) { x += 1; y += 1; } - v[off + k] = x; - if (x >= n && y >= m) return d; - } - } - return max; -} - -/** - * The bar on one declaration file pair. Returns `{ failures, reading }`. - * - * `rawDiffLines` counts the lines that differ IN PLACE when both files have the - * same line count (one per swapped union, as the ruling reads it), and the - * lines `diff` would print (deleted plus inserted) when the counts differ; - * `rawDiffMethod` names which. Before, a differing line count read as 0. - */ -function dtsBarFor(file, before, after) { - const failures = []; - const nb = normaliseUnionOrder(before); - const na = normaliseUnionOrder(after); - const bl = before.split('\n'); - const al = after.split('\n'); - let rawDiffLines = 0; - const rawDiffMethod = bl.length === al.length ? 'in-place' : 'diff'; - if (bl.length !== al.length) { - rawDiffLines = lineEditDistance(bl, al); - failures.push(`${file}: ${bl.length} lines at base, ${al.length} at head — a line added or removed is a declaration change, outside the ruling`); - } else { - for (let i = 0; i < bl.length; i += 1) { - if (bl[i] === al[i]) continue; - rawDiffLines += 1; - if (normaliseUnionOrder(bl[i]) !== normaliseUnionOrder(al[i])) { - failures.push(`${file}:${i + 1} differs other than by union member order:\n base: ${bl[i].trim()}\n head: ${al[i].trim()}`); - } - } - } - if (nb !== na) failures.push(`${file}: normalised base and head differ`); - const eb = dtsExportNames(before); - const ea = dtsExportNames(after); - if (JSON.stringify(eb) !== JSON.stringify(ea)) failures.push(`${file}: the export list differs (${eb.length} at base, ${ea.length} at head)`); - const control = injectVisibilityChange(after); - const controlSha = control === null ? null : sha256(normaliseUnionOrder(control)); - if (control === null) failures.push(`${file}: no private member to make public — the positive control could not run (INCONCLUSIVE)`); - else if (controlSha === sha256(nb)) failures.push(`${file}: the positive control normalises to the base hash — the normaliser hides a visibility change (INCONCLUSIVE)`); - return { - failures, - reading: { - file, - rawBase: sha256(before), rawHead: sha256(after), rawDiffLines, rawDiffMethod, - normalisedBase: sha256(nb), normalisedHead: sha256(na), - exportsBase: eb.length, exportsHead: ea.length, - positiveControl: controlSha, - }, - }; -} - -function dtsBar(beforeDir, afterDir) { - const failures = []; - const readings = []; - for (const file of ['index.d.ts', 'index.d.mts']) { - const b = join(beforeDir, file); - const a = join(afterDir, file); - if (!existsSync(b) || !existsSync(a)) refuse(`--dts: ${file} missing under ${existsSync(b) ? afterDir : beforeDir}`); - const r = dtsBarFor(file, readFileSync(b, 'utf8'), readFileSync(a, 'utf8')); - failures.push(...r.failures); - readings.push(r.reading); - } - return { failures, readings }; -} - // ── Reports ────────────────────────────────────────────────────────────────── function planReport(planned) { const out = []; const bySection = new Map(); for (const r of planned.records) { - const key = r.section ?? '(before the first banner)'; + const key = r.section ?? '(above the first banner)'; if (!bySection.has(key)) bySection.set(key, []); bySection.get(key).push(r); } @@ -1605,15 +1954,15 @@ function gitShow(ref, path) { return execFileSync('git', ['show', `${ref}:${path}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 28 }); } -/** A sibling of `sql-driver.ts` by basename, or null when there is none. */ -function readSibling(basename) { - const p = join(SRC_DIR, basename); +/** A file under `sql-driver.ts`'s directory by its relative path, or null when there is none. */ +function readSibling(rel) { + const p = join(SRC_DIR, rel); return existsSync(p) ? readFileSync(p, 'utf8') : null; } function generatedModulesOnDisk() { const out = new Map(); - for (const file of [LEAF_MODULE, ...Object.values(SECTION_MODULES)]) { + for (const file of [LEAF_MODULE, HOSTS_MODULE, CLASS_HEAD_SECTION[1], ...Object.values(SECTION_MODULES)]) { if (!file) continue; const p = join(SRC_DIR, file); if (existsSync(p)) out.set(file, readFileSync(p, 'utf8')); @@ -1626,7 +1975,6 @@ function generatedModulesOnDisk() { const USAGE = [ 'usage: node scripts/codemod-split-sql-driver.mjs [--write] plan (or apply) the split of an UNSPLIT sql-driver.ts', ' node scripts/codemod-split-sql-driver.mjs --verify --base REF re-prove a split on disk against sql-driver.ts at REF', - ' node scripts/codemod-split-sql-driver.mjs --dts BEFORE_DIST AFTER_DIST the declaration-file bar on two built dists', ' node scripts/codemod-split-sql-driver.mjs --self-test', ' node scripts/codemod-split-sql-driver.mjs --help', ].join('\n'); @@ -1649,17 +1997,6 @@ async function main(argv) { } return 0; } - if (argv.includes('--dts')) { - const i = argv.indexOf('--dts'); - const [before, after] = [argv[i + 1], argv[i + 2]]; - if (!before || !after) refuse('--dts needs '); - const { failures, readings } = dtsBar(before, after); - console.log(`normaliser (ruling 6348891160 item 2, verbatim): ${NORMALISER_ONE_LINER}`); - for (const r of readings) console.log(JSON.stringify(r)); - for (const f of failures) console.error(` ✗ ${f}`); - console.log(failures.length === 0 ? '✓ declaration-file bar: PASS' : `✗ declaration-file bar: FAIL (${failures.length})`); - return failures.length === 0 ? 0 : 1; - } const tm = await loadTsMorph(); if (argv.includes('--verify')) { const i = argv.indexOf('--base'); @@ -1696,6 +2033,24 @@ async function main(argv) { return 0; } + +/** + * The relative specifiers a module's VALUE imports and re-exports name: an + * `import type` / `export type` declaration, or a named list whose every + * member is `type`-marked, is erased by the compiler and is left out. + */ +function valueSpecifiers(src) { + const out = []; + for (const m of src.matchAll(/^[ \t]*(?:import|export)\s+(type\s+)?([^;'"]*?)\s*from\s*'(\.{1,2}\/[^']+)'/gm)) { + if (m[1]) continue; + const inner = /^\{([^}]*)\}$/.exec(m[2].trim()); + if (inner && inner[1].split(',').map((s) => s.trim()).filter(Boolean).every((s) => s.startsWith('type '))) continue; + out.push(m[3]); + } + for (const m of src.matchAll(/^[ \t]*import\s*'(\.{1,2}\/[^']+)'/gm)) out.push(m[1]); + return out; +} + // ── Self-test ──────────────────────────────────────────────────────────────── // // The proofs above are this script's whole value, and a proof that cannot fail @@ -1708,20 +2063,21 @@ async function main(argv) { let selfTestReachedVerdict = false; const SELF_TEST_BATTERIES = Object.freeze({ - 'declaration bar': 7, - 'stay rules': 12, + 'stay rules': 22, + 'facets, statics and docblocks': 18, 'top-level moves': 11, 'byte-identity proof': 10, - 'refusals': 5, + 'refusals': 6, }); const SELF_TEST_BATTERY_FLOOR = 5; 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) => plan(tm, text, - { sections: FIXTURE_SECTIONS, pins: FIXTURE_PINS, needles: FIXTURE_NEEDLES, classNeedles: FIXTURE_CLASS_NEEDLES }); +const fixturePlan = (tm, text, head = null) => plan(tm, text, + { sections: FIXTURE_SECTIONS, head, pins: FIXTURE_PINS, needles: FIXTURE_NEEDLES, classNeedles: FIXTURE_CLASS_NEEDLES }); function fixtureSource(extraMethods = '') { const pad = Array.from({ length: 12 }, (_, i) => ` const p${i} = ${i};`).join('\n'); @@ -1749,24 +2105,31 @@ function classReaderText(n: number): number { } export class SqlDriver { + private count = 0; private knex: any = null; protected logger: { warn(m: string): void } = console; - private count = 0; private readonly fn = (x: string): string => x; private static readonly SECRET = 1; + private static bump = 0; + + protected headMethod(): number { +${pad} + return this.count; + } // =================================== // Lifecycle // =================================== - /** Docblock that stays. */ - protected async movable(object: string, trx?: Knex.Transaction): Promise { + /** A private docblock: it moves with the body. */ + private async movable(object: string, trx?: Knex.Transaction): Promise { ${pad} const a = helperImported(object) as ExportedShape | string; this.logger.warn(String(a)); return this.other(String(a)) + String(trx ? this.count : 0); } + /** A protected docblock: it stays on the facade. */ protected other(x: string): string { return x; } @@ -1820,6 +2183,47 @@ ${pad} return SqlDriver.SECRET; } + protected bumpsStatic(): number { +${pad} + SqlDriver.bump += 1; + return SqlDriver.SECRET; + } + + protected namesClassType(): SqlDriver | null { +${pad} + return null; + } + + protected delete(): number { +${pad} + return 4; + } + + protected deletesField(): void { +${pad} + delete this.knex; + } + + protected destructured({ a }: { a: number }): number { +${pad} + return a; + } + + protected generic(x: T): T { +${pad} + return this.other(x) as T; + } + + static staticHelper(n: number): number { +${pad} + return n + SqlDriver.SECRET; + } + + static usesThis(): number { +${pad} + return this.SECRET; + } + protected pinnedMethod(): number { ${pad} return 3; @@ -1866,44 +2270,39 @@ async function selfTest() { }; const refused = (fn) => { try { fn(); return false; } catch (e) { return e instanceof CodemodRefusal; } }; - open('declaration bar'); - { - const base = 'export declare class A {\n private x;\n t: "b" | "a";\n}\nexport { A };\n'; - const swapped = base.replace('"b" | "a"', '"a" | "b"'); - check('a union-member swap passes, and is counted as one raw diff line', - (() => { const r = dtsBarFor('f', base, swapped); return r.failures.length === 0 && r.reading.rawDiffLines === 1; })()); - check('the normaliser sorts union members and nothing else', normaliseUnionOrder('x: "b" | "a"; y: B | A') === 'x: "a" | "b"; y: B | A'); - check('a retyped member fails', dtsBarFor('f', base, base.replace('"b" | "a"', '"b" | "c"')).failures.length > 0); - check('a private member made public fails (the positive control the bar runs)', - dtsBarFor('f', base, base.replace(' private x;', ' x;')).failures.length > 0); - check('an added line fails', dtsBarFor('f', base, base.replace('}\n', ' y: number;\n}\n')).failures.length > 0); - check('a line-count change is counted as diff counts it, never as 0', - (() => { const r = dtsBarFor('f', base, base.replace('}\n', ' y: number;\n}\n')).reading; return r.rawDiffLines === 1 && r.rawDiffMethod === 'diff'; })() - && lineEditDistance(['a', 'b', 'c'], ['a', 'x', 'c', 'd']) === 3); - check('a changed export list fails', dtsBarFor('f', base, base.replace('export { A };', 'export { A as B };')).failures.length > 0); - } - open('stay rules'); { const planned = fixturePlan(tm, fixtureSource()); const why = (name) => (planned.records.find((r) => r.name === name)?.reasons ?? ['']).join('; '); const stays = (name, code) => check(`${name} stays (${code})`, why(name).split('; ').some((r) => r.startsWith(code)), why(name)); const moves = (name, because) => check(`${name} moves (${because})`, why(name) === '', why(name)); - moves('movable', 'the canonical body'); + moves('movable', 'the canonical body, private members read through the host'); moves('readsLocal', 'lever (a)/(b): it imports the leaf value and the staying function'); moves('namesLocalType', 'a non-exported type it names moves to the leaf'); - moves('scoped', 'the widened chokepoint gate reads the module'); - stays('other', 'no-yield'); - stays('writes', 'this-write'); + moves('scoped', 'a method it calls goes as a closure'); + moves('other', 'a body no longer than its stub moves too: the facade gate counts bodies, not lines'); + moves('writes', 'a field it assigns goes through a host setter'); + stays('deletesField', 'this-write'); + moves('delete', 'a reserved word is a valid facet member name'); stays('asValue', 'method-as-value'); stays('inferred', 'inferred-return-type'); - stays('defaulted', 'parameter-shape'); - stays('readsStatic', 'non-public-static'); + moves('defaulted', 'the stub keeps the default and forwards the value'); + stays('destructured', 'parameter-shape'); + moves('generic', 'the stub calls the body instantiated with its type parameters'); + moves('staticHelper', 'a static whose body reads no `this`'); + stays('usesThis', 'static-this'); + moves('readsStatic', 'a private static it reads goes through SqlDriverStatics'); + stays('bumpsStatic', 'static-write'); + stays('namesClassType', 'static-route-conflict'); check('a pinned method and a method carrying a needle stay (path-reader)', why('pinnedMethod').startsWith('path-reader') && why('carriesNeedle').startsWith('path-reader'), `${why('pinnedMethod')} | ${why('carriesNeedle')}`); - check('a method carrying a class-scoped needle stays (path-reader): its reader reads no section module', - why('carriesClassNeedle').startsWith('path-reader'), why('carriesClassNeedle')); + check('a method in a section that maps to no module stays (section-kept)', why('keptSection').startsWith('section-kept'), why('keptSection')); + stays('headMethod', 'no-section'); + const withHead = fixturePlan(tm, fixtureSource(), FIXTURE_HEAD); + const headRecord = withHead.records.find((r) => r.name === 'headMethod'); + check('with a class-head section, a method above the first banner moves into its module', + headRecord && headRecord.reasons.length === 0 && headRecord.module === 'sql-driver-head.ts', headRecord ? headRecord.reasons.join('; ') : ''); } open('top-level moves'); @@ -1924,12 +2323,12 @@ async function selfTest() { const leaf = out.modules.get(LEAF_MODULE) ?? ''; check('the leaf header is line comments, never a docblock the bundled .d.ts would publish', leaf.includes(MARK) && !leaf.slice(0, leaf.indexOf('\n\n', leaf.indexOf(MARK))).includes('/*')); - check('the leaf imports neither sql-driver.ts nor a seam module', leaf.length > 0 && !/from '\.\/sql-driver/.test(leaf), leaf.slice(0, 200)); + check('the leaf imports neither sql-driver.ts nor a sql-driver-* module', leaf.length > 0 && !/from '\.\/sql-driver[.-]/.test(leaf), leaf.slice(0, 200)); check('sql-driver.ts re-exports the exported movers by name, the interface as a type', /export \{ DERIVED, type ExportedShape \} from '\.\/sql-driver-top-level\.js';/.test(out.host)); check('lever (a): the staying function a moved body reads is exported for its module, and the module imports it', /export \{ dependsOnPinned \};/.test(out.host) && - /import \{ dependsOnPinned \} from '\.\/sql-driver\.js';/.test(out.modules.get('sql-driver-lifecycle.ts') ?? '')); + /import \{ SqlDriverStatics as SqlDriver, dependsOnPinned \} from '\.\/sql-driver\.js';/.test(out.modules.get('sql-driver-lifecycle.ts') ?? '')); } open('byte-identity proof'); @@ -1939,7 +2338,7 @@ async function selfTest() { const out = generate(tm, input, planned); const again = generate(tm, input, fixturePlan(tm, input)); const v = verify(tm, input, out.host, out.modules); - check('the generated split proves against its base', v.failures.length === 0 && v.moved.length === 4 && v.movedTop.length === 5, + check('the generated split proves against its base', v.failures.length === 0 && v.moved.length === 12 && v.movedTop.length === 5, [...v.failures, `moved: ${v.moved.map((m) => m.name).join()}`, `top: ${v.movedTop.map((m) => m.name).join()}`].join(' | ')); 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]))); @@ -1964,14 +2363,67 @@ async function selfTest() { check('two moved declarations out of base order fail', swapped !== leaf && verify(tm, input, out.host, leafWith(swapped)).failures.length > 0); } + open('facets, statics and docblocks'); + { + const input = fixtureSource(); + const out = generate(tm, input, fixturePlan(tm, input)); + const mod = out.modules.get('sql-driver-lifecycle.ts') ?? ''; + check('the bodies move into one stateless facet class of static methods', /\nexport class LifecycleFacet \{\n/.test(mod) && + /\n static async movable\(this: Pick SqlDriver.SECRET },\n });\n }\n\n // ${MARK} Installs`) && !out.host.includes('bump:')); + check('a written field gets a host setter that assigns it on the driver', + out.host.includes(' get count() { return driver.count; },\n set count(v) { driver.count = v; },')); + check('a generic method calls its body instantiated with its own type parameters', + out.host.includes('return (LifecycleFacet.generic).call(lifecycleHost.of(this), x);') && mod.includes(' static generic(this: Pick= 3 && + out.host.includes("import { lifecycleHost } from './sql-driver-facet-hosts.js';")); + check('the host module keeps one FacetHost per facet and imports types only', + hostsMod.includes("export const lifecycleHost = new FacetHost('LifecycleFacet');") && + !/^import (?!type )/m.test(hostsMod) && hostsMod.includes("import type { LifecycleHost } from './sql-driver-lifecycle.js';")); + check('the builder factory goes into the host as a bound reference, never a forwarding closure', + out.host.includes(' get getBuilder() { return driver.getBuilder.bind(driver); },') && !out.host.includes('getBuilder: (...a)')); + check('a method goes into the host as a closure called on the driver at call time', + /\n [a-z]\w*: \(\.\.\.a\) => driver\.[a-z]\w*\(\.\.\.a\),\n/.test(out.host), out.host.slice(out.host.indexOf('Installs'))); + check('a stub handing another facet\'s host fails', verify(tm, input, + out.host.replace('.call(lifecycleHost.of(this), x)', '.call(otherHost.of(this), x)'), out.modules).failures.length > 0); + check('an edited host module fails', verify(tm, input, out.host, + new Map([...out.modules, [HOSTS_MODULE, hostsMod.replace('this.hosts.set(driver, host);', '')]])).failures.length > 0); + check('a written field\'s setter left out of the host fails', verify(tm, input, + out.host.replace(' set count(v) { driver.count = v; },\n', ''), out.modules).failures.length > 0); + check('a static method keeps its static stub and calls its facet body directly', + out.host.includes(' static staticHelper(n: number): number {\n // [#29520 codemod] The body moved to `sql-driver-lifecycle.ts`.\n return LifecycleFacet.staticHelper(n);\n }')); + const withMod = (text) => new Map([...out.modules, ['sql-driver-lifecycle.ts', text]]); + check('an edited moved docblock fails', verify(tm, input, out.host, + withMod(mod.replace('it moves with the body', 'it moved with the body'))).failures.length > 0); + check('an edited placeholder fails', verify(tm, input, out.host, + withMod(mod.replace(placeholderDoc('other'), placeholderDoc('other').replace('contract', 'promise')))).failures.length > 0); + check('a static forwarded as the wrong kind fails', verify(tm, input, + out.host.replace('SECRET: { get: () => SqlDriver.SECRET },', 'SECRET: { value: SqlDriver.SECRET },'), out.modules).failures.length > 0); + } + 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))); - check('a pin naming what the file lacks refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: { gone: 'x' }, needles: {}, classNeedles: {} }))); + 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 class-scoped needle the file no longer carries refuses', - refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: {}, needles: {}, classNeedles: { 'gone text': 'x' } }))); - check('a needle the file no longer carries refuses', refused(() => plan(tm, fixtureSource(), { sections: FIXTURE_SECTIONS, pins: {}, needles: { 'gone text': 'x' }, classNeedles: {} }))); + 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: {} }))); // `--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 736cccaff44..d20e850db32 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -9,6 +9,11 @@ "file": "packages/objectql/src/engine.ts", "class": "ObjectQL", "card": "#29986" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "class": "SqlDriver", + "card": "#30105" } ], "exceptions": [ @@ -371,6 +376,174 @@ "member": "serveReferenceNames", "card": "#30020", "reason": "gate reading: a hand-series host slot holds a try block" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "constructor", + "card": "#30020", + "reason": "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": "#30020", + "reason": "gate reading: the default sink's field initializer holds arrow functions (state owned by the instance)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "static DIALECT_CONNECT_TIMEOUT", + "card": "#30020", + "reason": "gate reading: a static table whose initializer holds arrow functions (state owned by the class)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "logDurabilityFailure", + "card": "#30147", + "reason": "path-reader (#30105 report: check-durability-degradation-log-level follows a catch's log helper to its body in the same file)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "get supports", + "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)" + }, + { + "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" + }, + { + "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" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "migrateMysqlDatetimeColumns", + "card": "#30147", + "reason": "path-reader (#30105 report: check-durability-degradation-log-level resolves its catch's log helper in the same file)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "migrateMysqlTimeColumns", + "card": "#30147", + "reason": "path-reader (#30105 report: check-durability-degradation-log-level resolves its catch's log helper in the same file)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "resolveWithheldFilterRefusal", + "card": "#30147", + "reason": "path-reader (#30105 report: sql-driver-compile-refusal-seam.test.ts enumerates the unsupportedFilterError callers)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "createAuditTimestampColumn", + "card": "#30147", + "reason": "path-reader (#30105 report: the packages/cli generate-builtin-id-column pin reads its text in sql-driver.ts)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "varcharColumnChars", + "card": "#30147", + "reason": "path-reader (#30105 report: check-keyed-text-bounds and the packages/cli generate-string-family-width pin read its text in sql-driver.ts)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "createColumn", + "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)" + }, + { + "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)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "createDatabase", + "card": "#30147", + "reason": "inferred-return-type (#30105 report: stays on the class)" + }, + { + "file": "packages/drivers/driver-sql/src/sql-driver.ts", + "member": "assertOperatorAppliesToColumn", + "card": "#30147", + "reason": "comment-in-parameters (#30105 report: stays on the class)" } ] }