Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
939ace0
feat(scripts): codemod that splits SqlDriver sections out of sql-driv…
claude Nov 2, 2026
3917686
refactor(driver-sql): generated split of sql-driver.ts by scripts/cod…
claude Nov 2, 2026
ba014df
fix(scripts): route the codemod's parses through ts-parse.mjs's check…
claude Nov 2, 2026
9414843
Merge commit 'refs/claude-29520/main' into claude/issue-29520-sql-dri…
claude Nov 2, 2026
4f6d088
Merge commit 'refs/claude-29520/main' into claude/issue-29520-sql-dri…
claude Nov 3, 2026
3c33663
feat(scripts): the codemod applies ruling 6591753301's widened cut pa…
claude Nov 3, 2026
39a5d9e
refactor(driver-sql): regenerate the sql-driver.ts split under the wi…
claude Nov 3, 2026
20f49a9
feat(scripts): check-tenant-chokepoint reads every sql-driver-*.ts se…
claude Nov 3, 2026
a5515ac
fix(scripts): the leaf's header is line comments, so the bundled .d.t…
claude Nov 3, 2026
db0a766
refactor(driver-sql): regenerate the split after the leaf header change
claude Nov 3, 2026
8b8f32d
fix(scripts): keep the two widening-ALTER bodies where the durability…
claude Nov 3, 2026
1cc66c9
refactor(driver-sql): regenerate the split with the durability-gate pin
claude Nov 3, 2026
5e2cef7
fix(scripts): list the chokepoint seam directory inside SCAN_FILES
claude Nov 3, 2026
272bd65
Merge remote-tracking branch 'origin/main' into claude/issue-29520-sq…
claude Nov 3, 2026
94bd4c0
refactor(driver-sql): regenerate the split on origin/main 2ea604223a
claude Nov 3, 2026
09d8603
Merge remote-tracking branch 'origin/main' into claude/issue-29520-sq…
claude Nov 3, 2026
2773c17
feat(scripts): the seam test reads the leaf, so its three needles bin…
claude Nov 3, 2026
d23e3c0
refactor(driver-sql): regenerate the split with the seam test reading…
claude Nov 3, 2026
a92c230
fix(spec): re-point the liveness anchor for unsupportedAggregationFil…
claude Nov 3, 2026
6cc535d
test(driver-sql): the receiver-detach pin reads every module split ou…
claude Nov 3, 2026
e2723ad
fix(scripts): codemod --help answers on a split tree; --dts counts a …
claude Nov 3, 2026
9f82207
Merge remote-tracking branch 'origin/main' into claude/issue-29520-sq…
claude Nov 3, 2026
77e178e
chore(scripts): ADR-0053 anchor entries follow the read-boundary fold…
claude Nov 3, 2026
77bd664
Merge remote-tracking branch 'origin/main' into claude/issue-29520-sq…
claude Nov 3, 2026
5b19e9a
chore(scripts): the sql-driver codemod takes ts-morph from a root dev…
claude Nov 3, 2026
010dd38
Merge remote-tracking branch 'origin/main' into claude/issue-29520-sq…
claude Nov 3, 2026
5f30cf9
fix(scripts): check-tenant-chokepoint declares sql-driver-balanced-gr…
claude Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@
"github-slugger": "2.0.0",
"semver": "^7.8.5",
"svelte": "^5.57.3",
"ts-morph": "^28.0.0",
"tsup": "^8.5.1",
"tsx": "^4.23.15",
"turbo": "^2.11.10",
Expand Down
40 changes: 30 additions & 10 deletions packages/drivers/driver-sql/src/logger-receiver-detach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@
* ⓪ the doubles (and the platform's real logger) are receiver-sensitive.
* ① the `info` site, driven through the real dev auto-reconcile path.
* ② the durability sites, driven through the real declared-index sync.
* ③ a structural pin over `sql-driver.ts` itself: no detach-then-call shape
* survives anywhere in the file, in ANY of the four spellings the sweep
* measured — including the two a single-line regex cannot see.
* ③ a structural pin over `sql-driver.ts` itself, and every non-test
* `sql-driver*.ts` module beside it (#29520: the codemod split moves bodies
* out of the host): no detach-then-call shape survives anywhere in them, in
* ANY of the four spellings the sweep measured — including the two a
* single-line regex cannot see.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync } from 'node:fs';
import ts from 'typescript';
import { createLogger } from '@objectstack/core';
import { SqlDriver } from './sql-driver.js';
Expand Down Expand Up @@ -493,11 +495,29 @@ describe('[#12792] ③ no detach-then-call shape survives in sql-driver.ts', ()
expect(found.filter((f) => f.line >= 13)).toEqual([]);
});

it('sql-driver.ts contains none of them', () => {
const source = readFileSync(new URL('./sql-driver.ts', import.meta.url), 'utf8');
expect(
scan(source, 'sql-driver.ts').map((f) => `${f.shape} @${f.line}: ${f.text.trim()}`),
'every log call must go through a property access so the receiver stays bound',
).toEqual([]);
// [#29520 codemod] The split moves `SqlDriver` method bodies (log calls
// included) out of `sql-driver.ts` into generated `sql-driver-*.ts` modules,
// and which modules exist depends on what the codemod moves on the `main` it
// is re-run on. So the pin reads the host and EVERY non-test `sql-driver*.ts`
// beside it by listing the directory, not by a named list that would rot at
// the next regeneration. The pin asserts an absence, so a file it stops
// reading would keep it green while it checked less; the floor below makes a
// listing that finds nothing past the host fail instead.
const SCANNED = readdirSync(new URL('.', import.meta.url))
.filter((name) => /^sql-driver.*\.ts$/.test(name) && !name.endsWith('.test.ts'))
.sort();

it('reads the host and every module split out of it', () => {
expect(SCANNED).toContain('sql-driver.ts');
expect(SCANNED.length).toBeGreaterThan(1);
});

it('sql-driver.ts and the modules split out of it contain none of them', () => {
const found = SCANNED.flatMap((name) =>
scan(readFileSync(new URL(`./${name}`, import.meta.url), 'utf8'), name).map(
(f) => `${name} ${f.shape} @${f.line}: ${f.text.trim()}`,
),
);
expect(found, 'every log call must go through a property access so the receiver stays bound').toEqual([]);
});
});
344 changes: 344 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-aggregation.ts

Large diffs are not rendered by default.

354 changes: 354 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-bulk-operations.ts

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-column-creation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#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`.
*
* A MOVE, not a redesign. Each function below 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.
*
* 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.
*
* 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 { 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.
*/
export interface ColumnCreationHost {
explainHashShadowDuplicate: SqlDriver['explainHashShadowDuplicate'];
explainRowSizeOverflow: SqlDriver['explainRowSizeOverflow'];
readonly knex: SqlDriver['knex'];
logDurabilityFailure: SqlDriver['logDurabilityFailure'];
}

/** The body of `SqlDriver#decorateHashShadowDuplicate`; the method's docblock there is its contract. */
export async function decorateHashShadowDuplicate(this: Pick<ColumnCreationHost, 'explainHashShadowDuplicate' | 'logDurabilityFailure'>, tableName: string, values: Record<string, unknown>, error: unknown): Promise<unknown> {
let explanation: string | null = null;
try {
explanation = await this.explainHashShadowDuplicate(tableName, values, error);
} catch {
// The disambiguation is a diagnostic. If it cannot be made, the original
// failure is still the failure — never mask it with this one.
return error;
}
if (!explanation) return error;
this.logDurabilityFailure(explanation);
return Object.assign(new Error(`${explanation} (server said: ${String((error as { message?: string })?.message ?? error)})`), {
code: (error as { code?: string })?.code,
cause: error,
});
}

/** The body of `SqlDriver#schemaBytesPerChar`; the method's docblock there is its contract. */
export async function schemaBytesPerChar(this: Pick<ColumnCreationHost, 'knex'>): 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 ' +
'from information_schema.CHARACTER_SETS cs ' +
'where cs.CHARACTER_SET_NAME = @@character_set_database',
);
const row = (Array.isArray(res) ? res[0]?.[0] : (res?.rows?.[0] ?? res?.[0])) ?? null;
const maxlen = Number(row?.maxlen ?? row?.MAXLEN);
const charset = String(row?.charset ?? row?.CHARSET ?? '');
if (!Number.isInteger(maxlen) || maxlen <= 0 || !charset) return null;
return { bytesPerChar: maxlen, charset };
} catch {
// Introspection is a nicety here; the advice below holds without it.
return null;
}
}

/** The body of `SqlDriver#rethrowWithRowSizeExplanation`; the method's docblock there is its contract. */
export async function rethrowWithRowSizeExplanation(this: Pick<ColumnCreationHost, 'explainRowSizeOverflow' | 'logDurabilityFailure'>, tableName: string, fields: Record<string, any> | undefined, keyedColumns: ReadonlyMap<string, { unique: boolean }>, addedColumns: string[] | null, e: any): Promise<never> {
const overflow = await this.explainRowSizeOverflow(tableName, fields, keyedColumns, addedColumns, e);
if (!overflow) throw e;
const said = String(e?.sqlMessage ?? e?.message ?? e);
this.logDurabilityFailure(overflow, said);
throw Object.assign(new Error(`${overflow} (server said: ${said})`), {
code: (e as { code?: string }).code,
cause: e,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
* 1. **The enumeration** reads `sql-driver.ts` and its cross-field seam module
* `sql-driver-cross-field-comparison.ts` (#25521 cut 13), plus the read-exit
* fault module `sql-driver-read-exit-fault.ts` (#25521 cut 15) and the
* predicate-emitter module `sql-driver-predicate-emitters.ts` (#25521 cut 16),
* predicate-emitter module `sql-driver-predicate-emitters.ts` (#25521 cut 16)
* and the generated top-level leaf `sql-driver-top-level.ts` (#29520 codemod),
* and lists every function that builds an `INVALID_FILTER` error. Only three may call
* `unsupportedFilterError` directly (the seam itself, the `'author'`
* re-issue, and the column refusal raised from a dialect error AFTER the
Expand Down Expand Up @@ -290,11 +291,14 @@ function enumerateRefusalBuilders(): {
// `unresolvableFilterColumnError`; the scan reads it too.
// [#25521 cut 16] The leaf predicate emitters moved to a fourth, with the two
// `$empty` refusals; the scan reads it too.
// [#29520 codemod] The top-level declarations ahead of the class moved to a
// generated leaf, with the refusal builders among them; the scan reads it too.
const sources = [
['sql-driver.ts', readFileSync(new URL('./sql-driver.ts', import.meta.url), 'utf8')],
['sql-driver-cross-field-comparison.ts', readFileSync(new URL('./sql-driver-cross-field-comparison.ts', import.meta.url), 'utf8')],
['sql-driver-read-exit-fault.ts', readFileSync(new URL('./sql-driver-read-exit-fault.ts', import.meta.url), 'utf8')],
['sql-driver-predicate-emitters.ts', readFileSync(new URL('./sql-driver-predicate-emitters.ts', import.meta.url), 'utf8')],
['sql-driver-top-level.ts', readFileSync(new URL('./sql-driver-top-level.ts', import.meta.url), 'utf8')],
] as const;
const directCallers = new Set<string>();
const seamCallers = new Set<string>();
Expand Down
185 changes: 185 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-crud.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#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`.
*
* A MOVE, not a redesign. Each function below 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.
*
* 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 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 { nanoid } from 'nanoid';
import { createHash } from 'node:crypto';
import {
AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, insertedRowsNotReadBackError,
} 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.
*/
export interface CrudHost {
applyTenantScope: SqlDriver['applyTenantScope'];
applyWriteColumnMap: SqlDriver['applyWriteColumnMap'];
auditMissingTenant: SqlDriver['auditMissingTenant'];
collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations'];
decorateHashShadowDuplicate: SqlDriver['decorateHashShadowDuplicate'];
fillAutoNumberFields: SqlDriver['fillAutoNumberFields'];
formatInput: SqlDriver['formatInput'];
formatOutput: SqlDriver['formatOutput'];
getBuilder: SqlDriver['getBuilder'];
injectTenantOnInsert: SqlDriver['injectTenantOnInsert'];
readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows'];
readBackInsertedRows: SqlDriver['readBackInsertedRows'];
remoteColumn: SqlDriver['remoteColumn'];
resolveTenantField: SqlDriver['resolveTenantField'];
resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax'];
rotationWriteTarget: SqlDriver['rotationWriteTarget'];
stampInsertTimestamps: SqlDriver['stampInsertTimestamps'];
}

/** The body of `SqlDriver#create`; the method's docblock there is its contract. */
export async function create(this: Pick<CrudHost, 'applyWriteColumnMap' | 'auditMissingTenant' | 'collidingAutoNumberReservations' | 'decorateHashShadowDuplicate' | 'fillAutoNumberFields' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'injectTenantOnInsert' | 'insertReturnsStoredRows' | 'readBackInsertedRows' | 'resyncSequenceToDataMax' | 'rotationWriteTarget' | 'stampInsertTimestamps'>, object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown>> {
const { _id, ...rest } = data;
const toInsert = { ...rest };

if (_id !== undefined && toInsert.id === undefined) {
toInsert.id = _id;
} else if (toInsert.id === undefined) {
toInsert.id = nanoid(DEFAULT_ID_LENGTH);
}

this.auditMissingTenant(object, 'create', options, [toInsert]);
this.injectTenantOnInsert(object, toInsert, options);

// #5495. The autonumber counter is bootstrapped from the data table exactly
// once, so any row that lands by a path bypassing `fillAutoNumberFields`
// leaves it permanently behind `MAX` — and every create then collided,
// burned a number and failed the request until it had ground past the
// seeded range one 409 at a time. Re-seed and retry instead, but ONLY when
// the collision is provably this counter's; see
// {@link collidingAutoNumberReservations} for how that is decided and why
// it cannot be decided from the error text alone.
//
// Retrying is confined to the no-caller-transaction case on purpose. Inside
// a caller's transaction there is nothing to repair here — the sequence
// UPDATE shares that transaction and rolls back with the refused INSERT, so
// no number is burned (measured) — and there is something to break: on
// Postgres a constraint failure aborts the whole transaction, so a retry
// issued on it could not succeed anyway. The caller owns that retry.
const mayRetry = options?.transaction === undefined;
for (let attempt = 0; ; attempt++) {
const reservations = await this.fillAutoNumberFields(object, toInsert, options);

// Rotation (ADR-0057 P2): the base name is a read-only view — new rows
// land in the current shard.
const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options);
const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
this.stampInsertTimestamps(object, formatted);

try {
if (this.insertReturnsStoredRows) {
const result = await builder.insert(formatted).returning('*');
return this.formatOutput(object, result[0]);
}
await builder.insert(formatted);
} catch (error) {
// #11627: on a table whose UNIQUE index is carried by a hash shadow,
// `ER_DUP_ENTRY` quotes a binary digest and names the shadow index, so
// neither the operator nor this driver can read WHICH value conflicted
// — and a genuine duplicate is indistinguishable from a (near-
// impossible) digest collision. Resolve it by reading the source
// columns back, on the failure path only, and re-throw the SAME error
// carrying whichever answer is true. Returns the error untouched when
// this is not a shadow conflict.
const shadowed = await this.decorateHashShadowDuplicate(
this.rotationWriteTarget(object) ?? object,
formatted,
error,
);
if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw shadowed;
const colliding = await this.collidingAutoNumberReservations(error, reservations, options);
if (colliding.length === 0) throw shadowed;
for (const reservation of colliding) {
await this.resyncSequenceToDataMax(reservation);
// Clear only what collided, so the next pass regenerates exactly
// those fields. A reservation that did NOT collide keeps its value
// rather than burning a second number for nothing.
delete toInsert[reservation.field];
}
continue;
}

// [#21227] Reached only when the INSERT landed and answered no rows. The
// read sits OUTSIDE the try on purpose: a fault in it is not an insert
// failure, so it must never reach the collision re-seed above, whose
// retry would re-issue a write that already landed.
const [stored] = await this.readBackInsertedRows(
object,
this.rotationWriteTarget(object) ?? object,
[toInsert],
options,
);
return stored;
}
}

/** The body of `SqlDriver#readBackInsertedRows`; the method's docblock there is its contract. */
export async function readBackInsertedRows(this: Pick<CrudHost, 'applyTenantScope' | 'formatOutput' | 'getBuilder' | 'remoteColumn' | 'resolveTenantField'>, object: string, writeTable: string, written: Record<string, any>[], options?: DriverOptions): Promise<Record<string, unknown>[]> {
if (written.length === 0) return [];
const idColumn = this.remoteColumn(object, 'id', 'id');
const tenantField = this.resolveTenantField(object);
const writtenTenants = tenantField
? [
...new Set(
written
.map((row) => row[tenantField])
.filter((value) => value !== undefined && value !== null && value !== '')
.map(String),
),
]
: [];
const everyRowCarriesTenant =
tenantField !== null
&& written.every((row) => row[tenantField] !== undefined && row[tenantField] !== null && row[tenantField] !== '');
const scopeOptions: DriverOptions = everyRowCarriesTenant
? { ...options, tenantId: writtenTenants[0], tenantIds: writtenTenants }
: { ...options };
const builder = this.getBuilder(writeTable, options);
this.applyTenantScope(builder, object, scopeOptions);
const stored: Record<string, any>[] = await builder.whereIn(
idColumn,
written.map((row) => row.id),
);
const byId = new Map<string, Record<string, any>>();
for (const row of stored) byId.set(String(row[idColumn]), row);
const missing = written.filter((row) => !byId.has(String(row.id))).map((row) => row.id);
if (missing.length > 0) throw insertedRowsNotReadBackError(object, missing, written.length);
return written.map((row) => 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 {
return createHash('sha256')
.update(`${object}\u001f${tenantId}\u001f${field}\u001f${scope}`)
.digest('hex');
}
Loading
Loading