diff --git a/packages/drivers/driver-sql/src/sql-driver-presumed-timestamp.ts b/packages/drivers/driver-sql/src/sql-driver-presumed-timestamp.ts new file mode 100644 index 00000000000..28c6eab163f --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-presumed-timestamp.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25521 cut 21] The presumed-timestamp update of `SqlDriver`: + * `updateWithPresumedTimestamp`, the UPDATE whose `updated_at` stamp is a + * presumption and that recovers, once per table, when the column turns out not + * to be there, and `resolveUpdatedAtColumnPresence`, the catalog question that + * recovery asks. 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 savepoint fence inside a caller transaction, the same + * `columnInfo()` probe, the same `present` / `absent` settling and the same + * warning. + * + * What did NOT move, and why: + * + * - Both methods' docblocks stay on the class: they are the contract, and the + * published `dist/*.d.ts` carries them with the methods. + * - The callers stay: `update` and `updateMany` hand the presumed-timestamp + * update the `issue` callback that owns the WHERE and the tenant scope. + * - `updatedAtStampIsPresumed`, `stampsUpdatedAt` and the other readers of + * `updatedAtColumnState` stay; the state map itself stays a class field. + * - `attemptWithoutPoisoning` is the sequence-counter path's fence too, and + * subclasses may override it; the body reaches it through the host. + * + * ## How the moved bodies reach the class + * + * The shape the other seam modules apply, after #23050. The class keeps both + * methods, because the published `dist/*.d.ts` lists their names and + * positions, and because an instance spy or a subclass method of the same name + * must still be the one `update` reaches. Each method is now one literal of + * exactly the {@link PresumedTimestampHost} members its body reads, plus a + * `.call`. Methods go as closures that call them on the driver at call time, + * so `updateWithPresumedTimestamp` still reaches `resolveUpdatedAtColumnPresence` + * through the driver, and a subclass override or an instance spy of either is + * still the one it reaches; `knex`, `logger`, `physicalTableByObject`, + * `physicalSchemaByObject` and `updatedAtColumnState` go as GETTERS, so each + * is read when the body reads it, as it was on the class. + * + * The module imports `sql-driver.ts` for a type only, so the two modules form + * no evaluation cycle. `index.ts` re-exports nothing from this module, so the + * published surface does not move. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +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, and naming a member the class does not have is a compile error. See + * the module header for which members are getters. + */ +export interface PresumedTimestampHost { + attemptWithoutPoisoning: SqlDriver['attemptWithoutPoisoning']; + readonly knex: SqlDriver['knex']; + readonly logger: SqlDriver['logger']; + readonly physicalSchemaByObject: SqlDriver['physicalSchemaByObject']; + readonly physicalTableByObject: SqlDriver['physicalTableByObject']; + resolveUpdatedAtColumnPresence: SqlDriver['resolveUpdatedAtColumnPresence']; + readonly updatedAtColumnState: SqlDriver['updatedAtColumnState']; +} + +/** The body of `SqlDriver#resolveUpdatedAtColumnPresence`; the method's docblock there is its contract. */ +export async function resolveUpdatedAtColumnPresence(this: Pick, object: string, trx?: Knex.Transaction): Promise { + const physical = this.physicalTableByObject[object] ?? object; + const runner = trx ?? this.knex; + let builder = runner(physical); + const remoteSchema = this.physicalSchemaByObject[object]; + if (remoteSchema) builder = builder.withSchema(remoteSchema); + let info: Record; + try { + info = (await builder.columnInfo()) as Record; + } catch { + // The probe itself failed — we learned nothing, so we may not claim the + // column is absent. The caller rethrows the write's own error. + return null; + } + const columns = Object.keys(info ?? {}); + if (columns.length === 0) return null; + return columns.includes('updated_at'); +} + +/** The body of `SqlDriver#updateWithPresumedTimestamp`; the method's docblock there is its contract. */ +export async function updateWithPresumedTimestamp(this: Pick, object: string, formatted: Record, options: DriverOptions | undefined, issue: (payload: Record, options?: DriverOptions) => Promise): Promise { + const unstamped = (): Record => { + const { updated_at: _dropped, ...rest } = formatted; + return rest; + }; + const settleAbsent = (): void => { + this.updatedAtColumnState.set(object, 'absent'); + this.logger.warn( + `[sql-driver] '${object}' has no physical 'updated_at' column, so this driver will not ` + + 'stamp one on update. The object is registered as managed, whose schema this ' + + "driver's own DDL would give `created_at`/`updated_at` — a table migrated out-of-band " + + 'without them will keep a stale "last modified" for every consumer that reads it ' + + '(list-view sorts, delta sync, cache invalidation, audit). Add the column, or accept ' + + 'that it is not tracked here.', + ); + }; + + const parentTrx = options?.transaction as Knex.Transaction | undefined; + if (parentTrx) { + const attempt = await this.attemptWithoutPoisoning(parentTrx, (scoped) => + issue(formatted, { ...options, transaction: scoped }), + ); + if (attempt.ok) { + this.updatedAtColumnState.set(object, 'present'); + return attempt.value; + } + const present = await this.resolveUpdatedAtColumnPresence(object, parentTrx); + if (present !== false) throw attempt.error; + settleAbsent(); + return issue(unstamped(), options); + } + + try { + const affected = await issue(formatted, options); + this.updatedAtColumnState.set(object, 'present'); + return affected; + } catch (error) { + const present = await this.resolveUpdatedAtColumnPresence(object); + if (present !== false) throw error; + settleAbsent(); + return issue(unstamped(), options); + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index fb5f2d33138..89b6a55f6ad 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -195,6 +195,9 @@ import { applyEmptyOperator, applyJsonMembership, applyLike, applyLikePattern, a // [#25521 cut 20] Date bucketing (`buildDateBucketExpr`); the method of the same name below hands the body what // it reads (`DateBucketHost`). import { buildDateBucketExpr } from './sql-driver-date-bucket.js'; +// [#25521 cut 21] The presumed-timestamp update; the two methods of the same names below hand the bodies what +// they read (`PresumedTimestampHost`). +import { resolveUpdatedAtColumnPresence, updateWithPresumedTimestamp } from './sql-driver-presumed-timestamp.js'; import { makeDatabaseDirectory, probeAbsentSqliteDatabaseFile, SqliteDatabaseFileError } from './sqlite-database-file.js'; import { jsonMembershipPredicate } from '@objectstack/core'; import knex, { Knex } from 'knex'; @@ -7510,26 +7513,20 @@ export class SqlDriver implements IDataDriver { * snapshot — and, on Postgres, so it runs on a transaction that is still * usable (see {@link updateWithPresumedTimestamp}). */ - protected async resolveUpdatedAtColumnPresence( + protected resolveUpdatedAtColumnPresence( object: string, trx?: Knex.Transaction, ): Promise { - const physical = this.physicalTableByObject[object] ?? object; - const runner = trx ?? this.knex; - let builder = runner(physical); - const remoteSchema = this.physicalSchemaByObject[object]; - if (remoteSchema) builder = builder.withSchema(remoteSchema); - let info: Record; - try { - info = (await builder.columnInfo()) as Record; - } catch { - // The probe itself failed — we learned nothing, so we may not claim the - // column is absent. The caller rethrows the write's own error. - return null; - } - const columns = Object.keys(info ?? {}); - if (columns.length === 0) return null; - return columns.includes('updated_at'); + // [#25521 cut 21] The body moved to `sql-driver-presumed-timestamp.ts` (see `PresumedTimestampHost`). + const readKnex = () => this.knex; + const readPhysicalSchemaByObject = () => this.physicalSchemaByObject; + const readPhysicalTableByObject = () => this.physicalTableByObject; + const host: ThisParameterType = { + get knex() { return readKnex(); }, + get physicalSchemaByObject() { return readPhysicalSchemaByObject(); }, + get physicalTableByObject() { return readPhysicalTableByObject(); }, + }; + return resolveUpdatedAtColumnPresence.call(host, object, trx); } /** @@ -7584,53 +7581,22 @@ export class SqlDriver implements IDataDriver { * caller owns the WHERE and the tenant scope, so this method never rebuilds * them and cannot get them wrong. */ - protected async updateWithPresumedTimestamp( + protected updateWithPresumedTimestamp( object: string, formatted: Record, options: DriverOptions | undefined, issue: (payload: Record, options?: DriverOptions) => Promise, ): Promise { - const unstamped = (): Record => { - const { updated_at: _dropped, ...rest } = formatted; - return rest; - }; - const settleAbsent = (): void => { - this.updatedAtColumnState.set(object, 'absent'); - this.logger.warn( - `[sql-driver] '${object}' has no physical 'updated_at' column, so this driver will not ` + - 'stamp one on update. The object is registered as managed, whose schema this ' + - "driver's own DDL would give `created_at`/`updated_at` — a table migrated out-of-band " + - 'without them will keep a stale "last modified" for every consumer that reads it ' + - '(list-view sorts, delta sync, cache invalidation, audit). Add the column, or accept ' + - 'that it is not tracked here.', - ); + // [#25521 cut 21] The body moved to `sql-driver-presumed-timestamp.ts` (see `PresumedTimestampHost`). + const readLogger = () => this.logger; + const readUpdatedAtColumnState = () => this.updatedAtColumnState; + const host: ThisParameterType = { + attemptWithoutPoisoning: (...a) => this.attemptWithoutPoisoning(...a), + get logger() { return readLogger(); }, + resolveUpdatedAtColumnPresence: (...a) => this.resolveUpdatedAtColumnPresence(...a), + get updatedAtColumnState() { return readUpdatedAtColumnState(); }, }; - - const parentTrx = options?.transaction as Knex.Transaction | undefined; - if (parentTrx) { - const attempt = await this.attemptWithoutPoisoning(parentTrx, (scoped) => - issue(formatted, { ...options, transaction: scoped }), - ); - if (attempt.ok) { - this.updatedAtColumnState.set(object, 'present'); - return attempt.value; - } - const present = await this.resolveUpdatedAtColumnPresence(object, parentTrx); - if (present !== false) throw attempt.error; - settleAbsent(); - return issue(unstamped(), options); - } - - try { - const affected = await issue(formatted, options); - this.updatedAtColumnState.set(object, 'present'); - return affected; - } catch (error) { - const present = await this.resolveUpdatedAtColumnPresence(object); - if (present !== false) throw error; - settleAbsent(); - return issue(unstamped(), options); - } + return updateWithPresumedTimestamp.call(host, object, formatted, options, issue); } /**