Skip to content

Commit 9b8a586

Browse files
refactor(driver-sql): the sql-driver codemod retires 15 facade-gate exception rows (knex calls, spelled return types, comments in parameters, accessors, computed defaults, the logger default) (#30147 phase 2a) (#30249)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBWMEECkfNfe2VjW9rMJG3n7)_ Part of #30147 (phase 2a of 2a-2d: the sql-driver codemod learns six mechanisms and retires 15 exception rows. 2b, 2c and 2d, then the phase 3 measurement, stay open on the card.) ## 维护者速读 - 改了什么:`scripts/codemod-split-sql-driver.mjs` 新增六种机制(knex 调用、写出返回类型、参数列表里的注释、getter、计算型参数默认值、`logger` 字段默认值),用它重新生成 `sql-driver.ts` 和 9 个模块(新增 `sql-driver-schema-introspection.ts`),登记表删去 15 条 sql-driver.ts 例外(88 → 73)。 - 为什么改:#30147 要求 codemod 自己移走这些"能力不足"的例外,而不是手改生成结果。 - 风险与回滚:方法体逐字节不变(171/171),公开 `.d.ts` 与 base 逐字节相同;回滚即 revert 本 PR。 - 先看:codemod 里四张新表 `RECEIVER_FREE_FIELDS` / `EXPLICIT_RETURN_TYPES` / `MOVED_ACCESSORS` / `MOVED_FIELD_DEFAULTS`,以及 `verify` 里对应的撤销逻辑。 - 两分钟验证:`node scripts/codemod-split-sql-driver.mjs --verify --base 21510bb`,预期 `171/171 moved bodies identical to base`、`sql-driver.ts 18334 -> 10717 lines`,退出 0;`node scripts/check-facade-delegation-only.mjs` 打印 73 条具名例外,退出 0。 - 需要你做的:无,席位审阅并落地。 Clause-②: no Metadata-protocol: none ## What changes The sql-driver codemod (`scripts/codemod-split-sql-driver.mjs`) learns six mechanisms. Each one is regenerated from the unsplit `sql-driver.ts` at `21510bba` and re-proved by `--verify` (undoing the split rebuilds the base byte for byte). No generated file is edited by hand. | mechanism | table or rule | members it moves | |:--|:--|:--| | a body may call `this.knex(...)` | `RECEIVER_FREE_FIELDS` (a Knex instance is the `function knex(tableName, options)` closure, which reads `knex.context`, never `this`) | `listPhysicalColumns`, `explainUnkeyableTextColumn`, `explainHashShadowDuplicate` (and `getBuilder`'s field-call half) | | the stub writes the return type the base leaves inferred | `EXPLICIT_RETURN_TYPES` (types copied from `dist/index.d.ts` built at base) | `getBuilder`, `applyFilters`, `ensureDatabaseExists`, `createDatabase` | | a comment between the parentheses moves with the base list verbatim | rule (the `comment-in-parameters` stay reason is gone) | `assertOperatorAppliesToColumn` | | a get accessor keeps its signature; its body moves | `MOVED_ACCESSORS` | `insertReturnsStoredRows`, `dateGranularityCapabilities`, `supportsWalJournal`, `dialectName` | | a computed parameter default is written optional on the stub (`nowMs?: number`) and stays where the body is | rule (the gate's own data-default reading, mirrored) | `rotateShards`, `ensureRotation` (hand-series stubs: the default stays in the seam function) | | a field initializer becomes a facet member typed as the field | `MOVED_FIELD_DEFAULTS` (instance fields only) | `logger` | `sql-driver.ts` goes from 10,979 to 10,717 lines. Moved bodies go from 158 to 171. Registry (`scripts/facade-delegation-registry.json`): the 15 rows those members held are deleted (the gate refuses a row whose member is already a delegation). Five rows that stay get their reason rewritten for the half that remains: - `applyFilterCondition`: inferred-return-type only (2d). - `ensureShardTable` and `initObjects`: path-reader only (their knex half is retired). - `get supports`: an accessor whose object type the base leaves inferred, with a docblock on each key. - `static DIALECT_CONNECT_TIMEOUT`: a static initializer runs while the class is evaluated, so a facet call there could meet its module in the temporal dead zone. | card | before | after | |:--|--:|--:| | #30020 | 17 | 17 | | #30054 | 16 | 16 | | #30055 | 6 | 6 | | #30147 | 49 | 34 (sql-driver.ts 27 → 12) | | total | 88 | 73 | `scripts/check-tenant-chokepoint.mjs` lists the new `sql-driver-schema-introspection.ts` in `DECLARED_WATCH_HINTS`, as its self-test requires for every scanned module. ## Tests - The codemod's `--self-test` gains a sixth pinned battery, "codemod-limit retirements (#30147)", with 31 cases: each mechanism moves only when its table names it; the generated text; two runs give one tree; the generated split proves; one edit per mechanism fails the proof (an unlisted return type, an edited written type, an edited comment, an edited getter body, an optional parameter spelled with another type, a facet member or seam that drops the default, a missing seam, an edited initializer, a field calling another member); every stale or wrong table row refuses. 98 cases in 6 batteries pass (67 in 5 before). The battery floor goes from 5 to 6. - No test file changes. The suites below ran at base `f2a9007e` and at head; the counts are equal. | suite | base | head | |:--|:--|:--| | driver-sql (`vitest run`) | 238 files passed, 12 skipped; 3,859 passed, 216 skipped | the same | | driver-turso (subclasses `SqlDriver`) | 97 files; 2,514 passed, 34 skipped | the same | | driver-sqlite-wasm (subclasses `SqlDriver`) | 40 files; 750 passed | the same | | cli by-path pins (9 files: generate-* pins, `clean.operator-text-21418`, migrate, schema-migrate) | 9 files; 116 passed | the same | | types / metadata / metadata-protocol by-path tests, spec `check-liveness.test` | 53 / 11 / 33 / 74 passed | the same | ## Acceptance notes - The four written return types came from the base's declaration output (`dist/index.d.ts` of `@objectstack/driver-sql` built at base), not from reading the source. - `get supports` and `static DIALECT_CONNECT_TIMEOUT` stay on #30147 with no sub-phase planned yet; 2b-2d cover the path-reader rows, `createColumn` and `applyFilterCondition`. - No changeset, as #30139 (`skip-changeset`): no published behaviour or surface changes (the public `.d.ts` is byte-identical). ## Verification Head `34fc09d0`, base `origin/main` `f2a9007e`. <details><summary>Proof readings</summary> - Byte-identity: `node scripts/codemod-split-sql-driver.mjs --verify --base 21510bb` exit 0: "171/171 moved bodies identical to base (body, parameters, return type, async); 127/127 moved top-level declarations identical to base; sql-driver.ts 18334 -> 10717 lines; undoing the split does rebuild the base byte-for-byte". - Determinism: two `--write` runs from the unsplit input give output tree sha256 `07b3a5053a23098c2754bb36caefb96532bbc4c7497ae29ab5dd357aff036454` both times (input sha256 `2d2ebebd…`). - Public `.d.ts`: `dts-public.mjs` over the base and head `dist` exit 0, `pass: true`, both files raw-equal (`index.d.ts` sha256 `92e2cfb2…` on both sides). `rotateShards` and `ensureRotation` read the same at base and head, `nowMs?: number` in both (signature hashes `c8bded77…` and `d0ab9226…` on both sides); so do `getBuilder` and `get dialectName`. - Typecheck: `pnpm --filter @objectstack/driver-sql typecheck` exit 0. - `check-tenant-chokepoint`: base and head both read 24 `getBuilder()` bindings, 21 read doors, 3 insert builders, 4 tenant predicates, 0 D8 arms (38 files at base, 39 at head: the new module); `--self-test` exit 0. - Facade gate: `node scripts/check-facade-delegation-only.mjs` exit 0, "3 facade file(s) hold delegations only, 73 named exception(s)"; `--self-test` exit 0, 36 cases (gate script and its cases untouched). - `node scripts/pm/dispatch-gates.mjs --commands`: 67 derived commands, each run, all exit 0; `--ran` reconciles 67 derived, 67 run, 0 NOT-MEASURED. Two first exited 3 on prerequisites (the shallow clone lacked the `check-plugin-teardown-shape` fixture commit; `check:dual-build-cjs-loads` needed unbuilt dists): after fetching that commit and building those packages, both exit 0. - Also run: the codemod, `check-tenant-chokepoint` and facade gate `--self-test`s (exit 0), `check:lean-entry-closure` (exit 0), eslint over the changed scripts and driver-sql sources (exit 0). - Changed lines 1,551 (+1,052 / -499), under the 3,000 threshold. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzj4B2cbnRLVdgtLcDZ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1d41037 commit 9b8a586

13 files changed

Lines changed: 1052 additions & 499 deletions

‎packages/drivers/driver-sql/src/sql-driver-class-head.ts‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
/**
44
* [#29520 codemod] The "Class head: dialect probes, logger, configuration"
5-
* section of `SqlDriver`: the bodies of `emissionClients`, `clientSpelling`,
6-
* `aliasIdentifierSql`, `dateBucketSql`, `knexConfigFor`, `withConnectBound`,
7-
* `withUtcSession`, `withMysqlCalendarDayAsText`,
5+
* section of `SqlDriver`: the bodies of `logger`, `emissionClients`,
6+
* `clientSpelling`, `insertReturnsStoredRows`, `aliasIdentifierSql`,
7+
* `dateGranularityCapabilities`, `dateBucketSql`, `knexConfigFor`,
8+
* `withConnectBound`, `withUtcSession`, `withMysqlCalendarDayAsText`,
89
* `withPostgresCalendarDayAsText`, `installQueryTiming`, `assertSchemaMutable`,
910
* `assertDialectEmits` moved out of `sql-driver.ts` whole by
1011
* `scripts/codemod-split-sql-driver.mjs`.
@@ -60,6 +61,8 @@ export interface ClassHeadHost {
6061
assertDialectEmits: SqlDriverClass['assertDialectEmits'];
6162
buildDateBucketExpr: SqlDriverClass['buildDateBucketExpr'];
6263
readonly config: SqlDriverClass['config'];
64+
readonly isMysql: SqlDriverClass['isMysql'];
65+
readonly isPostgres: SqlDriverClass['isPostgres'];
6366
readonly isSqlite: SqlDriverClass['isSqlite'];
6467
readonly knex: SqlDriverClass['knex'];
6568
openedEmptyInMemory: SqlDriverClass['openedEmptyInMemory'];
@@ -68,6 +71,14 @@ export interface ClassHeadHost {
6871

6972
/** The bodies of the "Class head: dialect probes, logger, configuration" section of `SqlDriver`: a stateless facet, called by the class's stubs. */
7073
export class ClassHeadFacet {
74+
/** The initializer of `SqlDriver#logger`; the field's declaration there is its contract. */
75+
static logger(): SqlDriverClass['logger'] {
76+
return {
77+
warn: (msg, meta) => console.warn(msg, meta ?? ''),
78+
error: (msg, meta) => console.error(msg, meta ?? ''),
79+
};
80+
}
81+
7182
/** The body of `SqlDriver#emissionClients`; the method's docblock there is its contract. */
7283
static emissionClients(): string[] {
7384
return [
@@ -90,11 +101,37 @@ export class ClassHeadFacet {
90101
return typeof c === 'string' ? c : '';
91102
}
92103

104+
/** The body of `SqlDriver#insertReturnsStoredRows`; the method's docblock there is its contract. */
105+
static insertReturnsStoredRows(this: Pick<ClassHeadHost, 'isPostgres' | 'isSqlite'>): boolean {
106+
return this.isSqlite || this.isPostgres;
107+
}
108+
93109
/** The body of `SqlDriver#aliasIdentifierSql`; the method's docblock there is its contract. */
94110
static aliasIdentifierSql(this: Pick<ClassHeadHost, 'knex'>, alias: string): string {
95111
return (this.knex.client as { wrapIdentifier(value: string): string }).wrapIdentifier(String(alias));
96112
}
97113

114+
/** The body of `SqlDriver#dateGranularityCapabilities`; the method's docblock there is its contract. */
115+
static dateGranularityCapabilities(this: Pick<ClassHeadHost, 'isMysql' | 'isPostgres' | 'isSqlite'>): Record<string, boolean> {
116+
if (this.isPostgres) {
117+
return { day: true, month: true, quarter: true, year: true, week: true };
118+
}
119+
if (this.isMysql) {
120+
return { day: true, month: true, quarter: true, year: true, week: true };
121+
}
122+
if (this.isSqlite) {
123+
// [#21595] All five, `week` included. The ISO week is computed by the
124+
// Thursday rule through date modifiers (the `week` arm of
125+
// `buildDateBucketExpr`), never `strftime('%V')` / `'%G'`: those arrived
126+
// in SQLite 3.46, and libSQL (`driver-turso`) bundles an older engine.
127+
// Measured: better-sqlite3 13.0.3 runs 3.53.4, `@libsql/client` 0.18.0
128+
// runs 3.45.1 (where `%V` answers NULL), sql.js 1.14.2
129+
// (`driver-sqlite-wasm`) runs 3.49.1. The expression runs on all three.
130+
return { day: true, month: true, quarter: true, year: true, week: true };
131+
}
132+
return {};
133+
}
134+
98135
/** The body of `SqlDriver#dateBucketSql`; the method's docblock there is its contract. */
99136
static dateBucketSql(this: Pick<ClassHeadHost, 'buildDateBucketExpr' | 'knex'>, objectName: string, field: string, granularity: DateGranularityValue): string | null {
100137
const bucket = this.buildDateBucketExpr(field, granularity, objectName);

‎packages/drivers/driver-sql/src/sql-driver-column-creation.ts‎

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
/**
44
* [#29520 codemod] The "Column creation helper" section of `SqlDriver`: the
55
* bodies of `declaredVarcharLength`, `keyableTextLength`,
6-
* `hashShadowColumnFor`, `createHashShadowUniqueIndex`,
7-
* `hashShadowColumnState`, `dropOrphanedHashShadowColumn`,
6+
* `explainUnkeyableTextColumn`, `hashShadowColumnFor`,
7+
* `createHashShadowUniqueIndex`, `hashShadowColumnState`,
8+
* `dropOrphanedHashShadowColumn`, `explainHashShadowDuplicate`,
89
* `decorateHashShadowDuplicate`, `hashShadowSourceColumns`,
910
* `varcharPackLength`, `rowWidthProfile`, `schemaBytesPerChar`,
1011
* `explainRowSizeOverflow`, `rethrowWithRowSizeExplanation`, `jsonColumn` moved
@@ -55,11 +56,14 @@ import type { SqlDriver as SqlDriverClass } from './sql-driver.js';
5556
* assigns.
5657
*/
5758
export interface ColumnCreationHost {
59+
declaredFieldsFor: SqlDriverClass['declaredFieldsFor'];
5860
explainHashShadowDuplicate: SqlDriverClass['explainHashShadowDuplicate'];
5961
explainRowSizeOverflow: SqlDriverClass['explainRowSizeOverflow'];
6062
hashShadowColumnState: SqlDriverClass['hashShadowColumnState'];
63+
hashShadowSourceColumns: SqlDriverClass['hashShadowSourceColumns'];
6164
readonly isMysql: SqlDriverClass['isMysql'];
6265
readonly isSqlite: SqlDriverClass['isSqlite'];
66+
keyableTextLength: SqlDriverClass['keyableTextLength'];
6367
readonly knex: SqlDriverClass['knex'];
6468
logDurabilityFailure: SqlDriverClass['logDurabilityFailure'];
6569
readonly logger: SqlDriverClass['logger'];
@@ -89,6 +93,101 @@ export class ColumnCreationFacet {
8993
return n;
9094
}
9195

96+
/** The body of `SqlDriver#explainUnkeyableTextColumn`; the method's docblock there is its contract. */
97+
static async explainUnkeyableTextColumn(this: Pick<ColumnCreationHost, 'declaredFieldsFor' | 'keyableTextLength' | 'knex'>, tableName: string, indexName: string, columns: string[], cause: unknown): Promise<string | null> {
98+
const code = (cause as { code?: string } | undefined)?.code;
99+
if (code !== 'ER_BLOB_KEY_WITHOUT_LENGTH' && code !== 'ER_TOO_LONG_KEY') return null;
100+
let offenders: string[] = [];
101+
try {
102+
const info: Record<string, { type?: string }> = await this.knex(tableName).columnInfo();
103+
offenders = columns.filter((c) => /text|blob/i.test(String(info?.[c]?.type ?? '')));
104+
} catch {
105+
// Introspection is a nicety here; the advice below holds without it.
106+
}
107+
const named =
108+
offenders.length > 0
109+
? `Column(s) ${offenders.map((c) => `"${c}"`).join(', ')} are stored as TEXT`
110+
: 'One or more of its key columns is stored as TEXT';
111+
112+
// Commit ebcc34e89: the SAME refusal has two causes, and the message below is true of
113+
// only one of them. On CREATE the field really declares no usable bound, so
114+
// "declare `maxLength`" is the fix. On an UPGRADE the bound IS declared —
115+
// the additive sync never rewrites a column's type (#3728), so a column
116+
// created before the declaration stays TEXT forever — and that same sentence
117+
// tells the operator to do what they already did, once per boot, in
118+
// production, which reads as the release they just deployed being broken.
119+
//
120+
// The discriminator is per COLUMN and needs BOTH halves, which is why it is
121+
// computed here rather than inferred from the error code: a bounded
122+
// declaration alone does not imply a stale column, because `ER_TOO_LONG_KEY`
123+
// can refuse a COMPOSITE whose parts are each individually keyable (see
124+
// {@link MAX_KEYABLE_VARCHAR_CHARS} — the bound is per KEY, not per column).
125+
// So the branch fires only where the physical column is TEXT *and*
126+
// {@link keyableTextLength} says a fresh create would have emitted
127+
// `varchar(n)` for it. When `columnInfo()` fails, `offenders` is empty and
128+
// this map stays empty, so the CREATE-path message stands — the honest
129+
// reading, since without introspection the two causes are indistinguishable.
130+
//
131+
// `declaredFieldsFor` returns `undefined` for a table this driver does not
132+
// own the column set of, and misses a SHARD table (registered under the base
133+
// name). Both degrade to the message below rather than guessing.
134+
const declaredFields = this.declaredFieldsFor(tableName);
135+
const staleBounds = new Map<string, number>();
136+
for (const c of offenders) {
137+
const field = declaredFields?.[c];
138+
const bound = field ? this.keyableTextLength(field) : null;
139+
if (bound !== null) staleBounds.set(c, bound);
140+
}
141+
142+
if (staleBounds.size > 0) {
143+
const staleNamed = [...staleBounds]
144+
.map(([c, n]) => `"${c}" (declares \`maxLength: ${n}\`)`)
145+
.join(', ');
146+
const [firstColumn, firstBound] = [...staleBounds][0];
147+
// Named in full, deliberately. The mirror case in `schema-drift.ts` (the
148+
// unbounded field over a stale `varchar`) already had to derive this, and
149+
// the two MySQL `MODIFY` hazards it names are recorded twice more in this
150+
// file, on the datetime and time widenings: MODIFY does NOT repeat a
151+
// `NOT NULL`, and it DROPS a `DEFAULT` it does not restate. An operator
152+
// who follows an abbreviated version of this remedy ends up worse off than
153+
// the no-op they started with — a column that lost its default.
154+
const others =
155+
offenders.length > staleBounds.size
156+
? ` Column(s) ${offenders
157+
.filter((c) => !staleBounds.has(c))
158+
.map((c) => `"${c}"`)
159+
.join(', ')} in the same key declare no usable bound and DO need \`maxLength\` ` +
160+
`declared on the field.`
161+
: '';
162+
return (
163+
`[sql-driver] cannot create index '${indexName}' on "${tableName}" — MySQL refuses a TEXT/BLOB ` +
164+
`column in a key without a key length. Column(s) ${staleNamed} are stored as TEXT even though the ` +
165+
`field ALREADY declares a usable \`maxLength\` — the column was created before that bound was ` +
166+
`declared, and the additive sync never rewrites a column's type, so re-declaring \`maxLength\` ` +
167+
`changes nothing here. REMEDY: convert the column(s) to varchar(n) by hand, with a backup ` +
168+
`taken first, restating the FULL column definition on MySQL (MODIFY drops a NOT NULL or DEFAULT you ` +
169+
`do not repeat) — e.g. ALTER TABLE \`${tableName}\` MODIFY \`${firstColumn}\` ` +
170+
`varchar(${firstBound}) plus that column's existing NOT NULL / DEFAULT clauses, restated verbatim; ` +
171+
`— and let the next boot create this index. ObjectStack does NOT rewrite the column for you: that ` +
172+
`ALTER needs an exclusive metadata lock on the table, which makes it a destructive, hard-to-roll-back ` +
173+
`action and a deliberate manual floor rather than a boot-time one.${others} The table exists but this ` +
174+
`index does NOT, so any uniqueness it declared is currently unenforced. A prefix index is deliberately ` +
175+
`not substituted: on a UNIQUE index it constrains the prefix rather than the value, and rejects two ` +
176+
`different values that share one.`
177+
);
178+
}
179+
180+
return (
181+
`[sql-driver] cannot create index '${indexName}' on "${tableName}" — MySQL refuses a TEXT/BLOB ` +
182+
`column in a key without a key length. ${named} because the field declares no \`maxLength\` (or one ` +
183+
`wider than ${SqlDriver.MAX_KEYABLE_VARCHAR_CHARS} characters, the most a utf8mb4 key part can hold). ` +
184+
`Declare \`maxLength\` on the field(s) so the column is emitted as varchar(n) and can be keyed. ` +
185+
`The table exists but this index does NOT, so any uniqueness it declared is currently ` +
186+
`unenforced. A prefix index is deliberately not substituted: on a UNIQUE index it constrains the ` +
187+
`prefix rather than the value, and rejects two different values that share one.`
188+
);
189+
}
190+
92191
/** The body of `SqlDriver#hashShadowColumnFor`; the method's docblock there is its contract. */
93192
static hashShadowColumnFor(indexName: string): string {
94193
// Commit cd1348802: DELEGATES rather than re-deriving. The differ has to look for
@@ -277,6 +376,85 @@ export class ColumnCreationFacet {
277376
return true;
278377
}
279378

379+
/** The body of `SqlDriver#explainHashShadowDuplicate`; the method's docblock there is its contract. */
380+
static async explainHashShadowDuplicate(this: Pick<ColumnCreationHost, 'hashShadowSourceColumns' | 'isMysql' | 'knex'>, tableName: string, values: Record<string, unknown>, cause: unknown): Promise<string | null> {
381+
if (!this.isMysql) return null;
382+
if ((cause as { code?: string } | undefined)?.code !== 'ER_DUP_ENTRY') return null;
383+
const message = String((cause as { message?: string } | undefined)?.message ?? '');
384+
// MySQL names the key as `table.index` (8.0) or `index` (5.7) — take the
385+
// last path segment either way.
386+
const keyed = /for key '([^']+)'/.exec(message);
387+
if (!keyed) return null;
388+
const indexName = keyed[1]!.split('.').pop()!;
389+
const shadow = SqlDriver.hashShadowColumnFor(indexName);
390+
let shadowExists = false;
391+
try {
392+
shadowExists = await this.knex.schema.hasColumn(tableName, shadow);
393+
} catch {
394+
return null;
395+
}
396+
if (!shadowExists) return null;
397+
const sources = await this.hashShadowSourceColumns(tableName, indexName);
398+
if (sources.length === 0) return null;
399+
// Only the source columns the failing write actually supplied; a partial
400+
// update cannot be re-selected on columns it never mentioned. A NULL-safe
401+
// key part (commit df1c75c4b) is exempt: the enforced key COALESCEs an absent or
402+
// NULL value into the '__global__' bucket, so its key part is knowable
403+
// without the write mentioning the column.
404+
if (
405+
!sources.every(
406+
(s) => s.nullSafe || Object.prototype.hasOwnProperty.call(values, s.column),
407+
)
408+
) {
409+
return null;
410+
}
411+
let existing = 0;
412+
try {
413+
// Re-select by the key the index ENFORCES, not by raw equality (commit df1c75c4b):
414+
// a NULL-safe part compares through the same COALESCE the generation
415+
// expression carries (NULL and absent both land in the global bucket),
416+
// and plain parts use `<=>` — MySQL's NULL-safe equality; this whole
417+
// route is inside `isMysql`. A bare `.where({col: null})` would compare
418+
// `= NULL`, match nothing, and misreport a genuine NULL-organization
419+
// duplicate as a HASH COLLISION.
420+
let q = this.knex(tableName);
421+
for (const s of sources) {
422+
q = s.nullSafe
423+
? q.whereRaw(`COALESCE(??, '${GLOBAL_TENANT}') = COALESCE(?, '${GLOBAL_TENANT}')`, [
424+
s.column,
425+
(values[s.column] ?? null) as any,
426+
])
427+
: q.whereRaw('?? <=> ?', [s.column, (values[s.column] ?? null) as any]);
428+
}
429+
const rows = await q.limit(1);
430+
existing = rows.length;
431+
} catch {
432+
return null;
433+
}
434+
// Describe the key parts as enforced — NULL-safe parts in their COALESCE
435+
// spelling — so the message names the actual constraint (commit df1c75c4b).
436+
const described = sources
437+
.map((s) => (s.nullSafe ? `COALESCE(${s.column}, '${GLOBAL_TENANT}')` : s.column))
438+
.join(', ');
439+
if (existing > 0) {
440+
// The ordinary case: a real duplicate. Say so in the declared terms
441+
// rather than leaving MySQL's binary digest as the only explanation.
442+
return (
443+
`[sql-driver] duplicate value for the UNIQUE constraint '${indexName}' on "${tableName}" ` +
444+
`(${described}). The constraint is physically carried by a hash-shadow column, so the ` +
445+
`server's own message quotes a binary digest instead of the value.`
446+
);
447+
}
448+
return (
449+
`[sql-driver] HASH COLLISION on the shadow-carried UNIQUE index '${indexName}' on "${tableName}" ` +
450+
`(${described}): the write was rejected as a duplicate, but NO existing row carries these ` +
451+
`values. Uniqueness on this index is enforced over a SHA-256 of them, so two different ` +
452+
`values produced the same digest. This is expected at a rate near 10^-59 for a billion rows — if ` +
453+
`you are reading this, please report it with the values above; the write itself is legitimate and ` +
454+
`is being refused.`
455+
);
456+
}
457+
280458
/** The body of `SqlDriver#decorateHashShadowDuplicate`; the method's docblock there is its contract. */
281459
static async decorateHashShadowDuplicate(this: Pick<ColumnCreationHost, 'explainHashShadowDuplicate' | 'logDurabilityFailure'>, tableName: string, values: Record<string, unknown>, error: unknown): Promise<unknown> {
282460
let explanation: string | null = null;

0 commit comments

Comments
 (0)