From a92731ec28863774f1f5ba42d269f6c136c9952a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:54:59 +0000 Subject: [PATCH] refactor(driver-sql): move the raw-execution path out of sql-driver.ts into sql-driver-raw-execution.ts (#25521 cut 2) Move-only. The bodies of SqlDriver#execute, #rawStatementFault and #noteRuntimeIndexDdl move unedited into a new module and reach the class through one named host (RawExecutionHost): knex, logger and runtimeCreatedIndexes as getters, rawStatementFault and noteRuntimeIndexDdl as late-bound closures so subclass overrides and spies still dispatch. The class keeps each method's name, signature, docblock and position. The seven module-level declarations the bodies read stay in sql-driver.ts and gain an `export` so the new module can import them; index.ts re-exports nothing new. Claude-Session: https://claude.ai/code/session_01RvoeGVyNRC3zkVrNsLbm8M Co-authored-by: Claude --- .../src/sql-driver-raw-execution.ts | 133 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 90 +++++------- 2 files changed, 166 insertions(+), 57 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-raw-execution.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-raw-execution.ts b/packages/drivers/driver-sql/src/sql-driver-raw-execution.ts new file mode 100644 index 00000000000..73bbd51b5e3 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-raw-execution.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25521 cut 2] The raw-execution path of `SqlDriver`: `execute`, its refusal + * terminal `rawStatementFault`, and `noteRuntimeIndexDdl`, which records the + * index DDL `execute` ran in the `runtimeCreatedIndexes` ledger. They moved out + * of `sql-driver.ts` whole. + * + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` + * method of the same name as it stood, re-indented one level and otherwise + * unedited: the same statement handling, the same redacted refusal line, the + * same ledger rules. + * + * ## How the moved bodies reach the class + * + * The shape `sql-driver-introspection.ts` (cut 1) applies, after #23050. The + * class keeps every method, because the published `dist/*.d.ts` lists each + * one's name, docblock and position. `TursoDriver` overrides `execute` and + * calls `super.execute`, and calls `rawStatementFault` from its remote + * transport. Each body is now one literal of exactly the + * {@link RawExecutionHost} members that method reads, plus a `.call`. `execute` + * is no longer `async`: it returns the moved function's own promise, so a + * caller awaits the same promise the body settles, with no extra wrapper. + * + * `execute` reads `rawStatementFault` and `noteRuntimeIndexDdl` through + * closures that call them on the driver at call time. So a subclass override + * (a test subclass overrides `rawStatementFault` and calls `super`) or a test + * spy on the instance is still the one the body reaches. + * + * The fields `knex`, `logger` and `runtimeCreatedIndexes` go as GETTERS, so + * each read happens when the body makes it, as it did on the class. The + * ledger itself stays a field of the class: schema sync reads it as well. + * + * The module-level declarations the bodies read stay in `sql-driver.ts`: + * `OWN_STATEMENT_FAULT`, `rawStatementFaultError`, the three index-DDL patterns + * and the two identifier helpers. They are exported from there and imported + * here. Other packages' tests cite `rawStatementFaultError` in `sql-driver.ts` + * by path, and `OWN_STATEMENT_FAULT` has readers that did not move. The module + * cycle is evaluation-safe because each value import is read only inside a + * function body, never while the two modules are first evaluated. `index.ts` + * re-exports nothing from this module, so the published surface does not move. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import { redactStatementFromMessage } from '@objectstack/types'; +import type { Knex } from 'knex'; +import type { SqlDriver } from './sql-driver.js'; +import { + CREATE_INDEX_DDL, + DROP_INDEX_DDL, + INDEX_DDL_PREFIX, + lastIdentifierSegment, + OWN_STATEMENT_FAULT, + rawStatementFaultError, + unquoteSqlIdentifier, +} from './sql-driver.js'; + +/** + * The `SqlDriver` members the functions below read — every one of them, and + * no other; each function's `this` is the `Pick` of the members IT reads. 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, and naming a + * member the class does not have is a compile error. See the module header + * for which members are getters. + */ +export interface RawExecutionHost { + readonly knex: SqlDriver['knex']; + readonly logger: SqlDriver['logger']; + noteRuntimeIndexDdl: SqlDriver['noteRuntimeIndexDdl']; + rawStatementFault: SqlDriver['rawStatementFault']; + readonly runtimeCreatedIndexes: SqlDriver['runtimeCreatedIndexes']; +} + +/** The body of `SqlDriver#rawStatementFault`; the method's docblock there is its contract. */ +export function rawStatementFault(this: Pick, command: string, error: unknown): Error { + const declared = (error as { status?: unknown } | null | undefined)?.status; + if (typeof declared === 'number') return error as Error; + + const detail = (error as { message?: unknown } | null | undefined)?.message; + const code = (error as { code?: unknown } | null | undefined)?.code; + // [#21385] ⛔ `command` is not written. It is the text the caller sent, and a + // raw statement may spell its values inline as well as bind them; the + // dialect's text leads with the same statement, compiled, and is cut below. + this.logger.warn( + '[sql-driver] DATABASE_ERROR — the backend refused a raw statement' + + (typeof code === 'string' && code.length > 0 ? ` (${code})` : '') + + '. The dialect diagnostic, with the statement and its bound literals cut: ' + + redactStatementFromMessage(typeof detail === 'string' ? detail : String(error), OWN_STATEMENT_FAULT), + ); + return rawStatementFaultError(error); +} + +/** The body of `SqlDriver#execute`; the method's docblock there is its contract. */ +export async function execute(this: Pick, command: any, params?: any[], options?: DriverOptions): Promise { + if (typeof command !== 'string') { + return command; + } + + const builder = + options?.transaction + ? this.knex.raw(command, params || []).transacting(options.transaction as Knex.Transaction) + : this.knex.raw(command, params || []); + + let result: unknown; + try { + result = await builder; + } catch (error) { + // [#16019] The raw path's terminal — see {@link SqlDriver.rawStatementFault}. + throw this.rawStatementFault(command, error); + } + // Only after the statement actually succeeded — an index we failed to + // create is not one we own (#4884). + if (INDEX_DDL_PREFIX.test(command)) this.noteRuntimeIndexDdl(command); + return result; +} + +/** The body of `SqlDriver#noteRuntimeIndexDdl`; the method's docblock there is its contract. */ +export function noteRuntimeIndexDdl(this: Pick, sql: string): void { + const created = CREATE_INDEX_DDL.exec(sql); + if (created) { + const table = lastIdentifierSegment(created[2]); + let names = this.runtimeCreatedIndexes.get(table); + if (!names) this.runtimeCreatedIndexes.set(table, (names = new Set())); + names.add(unquoteSqlIdentifier(created[1])); + return; + } + const dropped = DROP_INDEX_DDL.exec(sql); + if (!dropped) return; + // SQLite / Postgres `DROP INDEX` names no table, so forget the name + // wherever it is recorded — the ledger must never outlive the index. + const name = lastIdentifierSegment(dropped[1]); + for (const names of this.runtimeCreatedIndexes.values()) names.delete(name); +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 0da571b56d5..c1187581011 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -170,6 +170,11 @@ import { introspectPrimaryKeys, introspectUniqueConstraints, } from './sql-driver-introspection.js'; +// [#25521 cut 2] The raw-execution path (`execute`, its refusal terminal +// `rawStatementFault`, and `noteRuntimeIndexDdl`, which keeps the ledger of the +// index DDL `execute` ran); the methods of the same names below hand each body +// the members it reads (`RawExecutionHost`). +import { execute, noteRuntimeIndexDdl, rawStatementFault } from './sql-driver-raw-execution.js'; import { makeDatabaseDirectory, probeAbsentSqliteDatabaseFile, SqliteDatabaseFileError } from './sqlite-database-file.js'; import { jsonMembershipPredicate } from '@objectstack/core'; import knex, { Knex } from 'knex'; @@ -243,26 +248,26 @@ const DEFAULT_ID_LENGTH = 16; * text whatever word it opens with. The shared cut then runs without asking the * leak predicate, exactly as it does at the engine's raw-statement door. */ -const OWN_STATEMENT_FAULT = { statementSent: true } as const; +export const OWN_STATEMENT_FAULT = { statementSent: true } as const; // ── Raw index DDL the driver executes on the framework's behalf (#4884) ────── /** An SQL identifier in any dialect's quoting, or bare. */ const SQL_IDENTIFIER = '(?:`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][A-Za-z0-9_$]*)'; /** Cheap prefilter so ordinary `execute()` traffic never touches the parsers below. */ -const INDEX_DDL_PREFIX = /^\s*(?:create|drop)\s+(?:unique\s+)?index\b/i; -const CREATE_INDEX_DDL = new RegExp( +export const INDEX_DDL_PREFIX = /^\s*(?:create|drop)\s+(?:unique\s+)?index\b/i; +export const CREATE_INDEX_DDL = new RegExp( `^\\s*create\\s+(?:unique\\s+)?index\\s+(?:concurrently\\s+)?(?:if\\s+not\\s+exists\\s+)?` + `(${SQL_IDENTIFIER})\\s+on\\s+(${SQL_IDENTIFIER}(?:\\.${SQL_IDENTIFIER})?)`, 'i', ); -const DROP_INDEX_DDL = new RegExp( +export const DROP_INDEX_DDL = new RegExp( `^\\s*drop\\s+index\\s+(?:concurrently\\s+)?(?:if\\s+exists\\s+)?` + `(${SQL_IDENTIFIER}(?:\\.${SQL_IDENTIFIER})?)`, 'i', ); /** Peel one layer of `"…"` / `` `…` `` / `[…]` quoting off an identifier. */ -function unquoteSqlIdentifier(raw: string): string { +export function unquoteSqlIdentifier(raw: string): string { const s = raw.trim(); if (s.length >= 2) { const first = s[0]; @@ -279,7 +284,7 @@ function unquoteSqlIdentifier(raw: string): string { * `"public"."sys_metadata"` → `sys_metadata`. Tokenised rather than split on * `.`, so a dot INSIDE a quoted identifier does not become a separator. */ -function lastIdentifierSegment(raw: string): string { +export function lastIdentifierSegment(raw: string): string { const parts = raw.match(new RegExp(SQL_IDENTIFIER, 'g')); return unquoteSqlIdentifier(parts && parts.length > 0 ? parts[parts.length - 1] : raw); } @@ -1174,7 +1179,7 @@ function backendStatementFaultError(object: string, cause: unknown, targetedTabl * @param cause - the dialect error, kept whole for the log and for * cause-following predicates. */ -function rawStatementFaultError(cause: unknown): Error { +export function rawStatementFaultError(cause: unknown): Error { const err = new Error( 'The database refused to run a raw statement. The driver could not attribute the failure ' + 'to any part of the request, so no verdict about the statement is claimed here. The ' + @@ -10820,21 +10825,12 @@ export class SqlDriver implements IDataDriver { * (`TursoDriver` in remote mode) declares through the SAME composition. */ protected rawStatementFault(command: string, error: unknown): Error { - const declared = (error as { status?: unknown } | null | undefined)?.status; - if (typeof declared === 'number') return error as Error; - - const detail = (error as { message?: unknown } | null | undefined)?.message; - const code = (error as { code?: unknown } | null | undefined)?.code; - // [#21385] ⛔ `command` is not written. It is the text the caller sent, and a - // raw statement may spell its values inline as well as bind them; the - // dialect's text leads with the same statement, compiled, and is cut below. - this.logger.warn( - '[sql-driver] DATABASE_ERROR — the backend refused a raw statement' + - (typeof code === 'string' && code.length > 0 ? ` (${code})` : '') + - '. The dialect diagnostic, with the statement and its bound literals cut: ' + - redactStatementFromMessage(typeof detail === 'string' ? detail : String(error), OWN_STATEMENT_FAULT), - ); - return rawStatementFaultError(error); + // [#25521 cut 2] The body moved to `sql-driver-raw-execution.ts` (see `RawExecutionHost`). + const readLogger = () => this.logger; + const host: ThisParameterType = { + get logger() { return readLogger(); }, + }; + return rawStatementFault.call(host, command, error); } /** @@ -10860,27 +10856,15 @@ export class SqlDriver implements IDataDriver { * a caller dereference the result unchecked; pinned by * `sql-driver-doors-declared-types.test.ts`. */ - async execute(command: any, params?: any[], options?: DriverOptions): Promise { - if (typeof command !== 'string') { - return command; - } - - const builder = - options?.transaction - ? this.knex.raw(command, params || []).transacting(options.transaction as Knex.Transaction) - : this.knex.raw(command, params || []); - - let result: unknown; - try { - result = await builder; - } catch (error) { - // [#16019] The raw path's terminal — see {@link SqlDriver.rawStatementFault}. - throw this.rawStatementFault(command, error); - } - // Only after the statement actually succeeded — an index we failed to - // create is not one we own (#4884). - if (INDEX_DDL_PREFIX.test(command)) this.noteRuntimeIndexDdl(command); - return result; + execute(command: any, params?: any[], options?: DriverOptions): Promise { + // [#25521 cut 2] The body moved to `sql-driver-raw-execution.ts` (see `RawExecutionHost`). + const readKnex = () => this.knex; + const host: ThisParameterType = { + get knex() { return readKnex(); }, + noteRuntimeIndexDdl: (...a) => this.noteRuntimeIndexDdl(...a), + rawStatementFault: (...a) => this.rawStatementFault(...a), + }; + return execute.call(host, command, params, options); } /** @@ -10904,20 +10888,12 @@ export class SqlDriver implements IDataDriver { /** Record (or, on a DROP, forget) an index this driver just created via raw DDL. */ protected noteRuntimeIndexDdl(sql: string): void { - const created = CREATE_INDEX_DDL.exec(sql); - if (created) { - const table = lastIdentifierSegment(created[2]); - let names = this.runtimeCreatedIndexes.get(table); - if (!names) this.runtimeCreatedIndexes.set(table, (names = new Set())); - names.add(unquoteSqlIdentifier(created[1])); - return; - } - const dropped = DROP_INDEX_DDL.exec(sql); - if (!dropped) return; - // SQLite / Postgres `DROP INDEX` names no table, so forget the name - // wherever it is recorded — the ledger must never outlive the index. - const name = lastIdentifierSegment(dropped[1]); - for (const names of this.runtimeCreatedIndexes.values()) names.delete(name); + // [#25521 cut 2] The body moved to `sql-driver-raw-execution.ts` (see `RawExecutionHost`). + const readRuntimeCreatedIndexes = () => this.runtimeCreatedIndexes; + const host: ThisParameterType = { + get runtimeCreatedIndexes() { return readRuntimeCreatedIndexes(); }, + }; + return noteRuntimeIndexDdl.call(host, sql); } // ===================================