Skip to content

Commit 107bb4b

Browse files
os-warrenclaude
andauthored
fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4) (#12198)
* fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4) On utf8mb4 InnoDB a key part holds at most 3072 bytes (768 characters), so a full-value UNIQUE index over a longer column is inexpressible rather than merely expensive. Measured on live MySQL 8.0.46: 7 of 44 exported platform objects failed syncSchema (6 ER_BLOB_KEY_WITHOUT_LENGTH + 1 ER_TOO_LONG_KEY); Postgres 16.13 took all 44. Such a UNIQUE index is now carried by a driver-owned `<index>__hash` column: a STORED GENERATED VARBINARY(32) holding the full, untruncated SHA-256 of the key values. A generated column rather than an application-computed one so every writer maintains it, existing rows are hashed by the ALTER itself, and no write can disagree with its source columns. Selected by the server's own error code, only after the direct index is refused: Postgres and SQLite never refuse and are byte-identical to before. Non-unique indexes stay refused — an index over a digest accelerates no lookup the planner can reach without rewriting the read side. The drift differ learns the shadow is driver-owned, so its orphan pass cannot propose dropping the column that carries a live constraint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * test(driver-sql): move #11374's refusal pins to the non-unique case #11627 made a UNIQUE index over an unkeyable column expressible — carried on a hash-shadow column — so the two pins that asserted "unkeyable ⇒ refused" for UNIQUE objects were pinning a branch the ruling deliberately replaced. Rewritten rather than deleted or silenced. The refusal is not gone: it is the disposition for a NON-UNIQUE unkeyable index, where a digest serves no lookup the planner can reach. Both refusal pins keep a live subject via new non-unique fixtures, and a new pin asserts the unique cases land on a shadow whose key part reports no sub_part — so the constraint moving onto a shadow cannot quietly become the prefix constraint the ruling rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o * fix(driver-sql): name a shadow-carried duplicate instead of MySQL's binary digest Once uniqueness is enforced over a SHA-256 shadow, ER_DUP_ENTRY quotes the raw digest and names the shadow index, so a genuine duplicate and a digest collision are indistinguishable from the error alone and neither says which value conflicted. `create` now resolves it with one read on the failure path: a row matching the source columns is a real duplicate, named in the declared terms; no such row is a collision, named as such and asked to be reported. Wired into `create` only — `update` issues through three paths with no shared catch, and every flow in this repo that writes these columns inserts. Named in the docblock rather than left to be discovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd391ce commit 107bb4b

5 files changed

Lines changed: 791 additions & 15 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
---
4+
5+
driver-sql (MySQL): carry an over-long UNIQUE index on a hash-shadow column
6+
7+
On utf8mb4 InnoDB a key part holds at most 3072 bytes (768 characters), so a
8+
full-value UNIQUE index over a longer column is inexpressible — an OAuth access
9+
token that is a multi-KB JWT cannot be made keyable by any declared bound.
10+
Measured on live MySQL 8.0.46, 7 of 44 exported platform objects failed
11+
`syncSchema` outright and landed registered with their declared uniqueness
12+
absent (Postgres 16.13: 0 of 44).
13+
14+
Such a UNIQUE index is now carried by a driver-owned `<index>__hash` column — a
15+
`STORED GENERATED` `VARBINARY(32)` holding the full, untruncated SHA-256 of the
16+
key values — with the unique index on that column. Uniqueness is still enforced
17+
over the whole value: distinct values sharing a long prefix are both accepted
18+
(the property that ruled out prefix-unique indexes), NULLs stay distinct, and a
19+
composite tuple containing NULL conflicts with nothing.
20+
21+
The shadow is created only *after* the server refuses the direct index, so the
22+
dialect divergence is selected by the error code rather than by a dialect check:
23+
Postgres and SQLite are byte-identical to before. Non-unique indexes are
24+
deliberately left refused — an index over a digest accelerates no lookup the
25+
planner can reach.

packages/drivers/driver-sql/src/schema-drift.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,36 @@ export interface ManagedDriftEntry extends SchemaDiffEntry {
327327
/** Columns the driver creates unconditionally — never metadata fields. */
328328
export const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);
329329

330+
/**
331+
* Suffix of a HASH-SHADOW column — the driver-owned column that carries a
332+
* declared UNIQUE index MySQL cannot express over the values themselves
333+
* (#11627). See `SqlDriver.createHashShadowUniqueIndex` for why it exists and
334+
* what it stores.
335+
*/
336+
export const HASH_SHADOW_SUFFIX = '__hash';
337+
338+
/**
339+
* Is this physical column a driver-owned hash shadow (#11627)?
340+
*
341+
* ⚠️ Load-bearing for the ORPHAN differ below, and the reason this predicate
342+
* is exported rather than inlined. A hash-shadow column exists in the database
343+
* and — by construction — in no metadata field, which is the exact shape the
344+
* orphan pass reports as `unmapped_column` with a `drop_column` op. Dropping it
345+
* would take the UNIQUE index it carries with it, silently returning the object
346+
* to "registered but its declared uniqueness unenforced" — the very state
347+
* #11374/#11627 exist to end, reached this time through the migration tool
348+
* rather than through a refused DDL.
349+
*
350+
* Matched by SUFFIX rather than by a registry of known names, deliberately: the
351+
* differ runs against a database whose metadata it is comparing to, and a
352+
* shadow whose declared index has since been removed must still be recognised
353+
* as driver-owned (it is then cleaned up by the index's own removal path, not
354+
* by a blind column drop).
355+
*/
356+
export function isHashShadowColumn(name: string): boolean {
357+
return name.endsWith(HASH_SHADOW_SUFFIX);
358+
}
359+
330360
/** Minimal shape of an introspected physical column (see SqlDriver.introspectColumns). */
331361
export interface PhysicalColumn {
332362
name: string;
@@ -838,6 +868,10 @@ export function diffManagedTable(args: {
838868
// ── orphaned columns (physical column, no metadata field) ──────────
839869
for (const col of columns) {
840870
if (BUILTIN_COLUMNS.has(col.name)) continue;
871+
// Driver-owned hash shadow (#11627), never a metadata field — see
872+
// {@link isHashShadowColumn} for why dropping it as an orphan would
873+
// silently disable the UNIQUE constraint it carries.
874+
if (isHashShadowColumn(col.name)) continue;
841875
if (expectedColumns.has(col.name)) continue;
842876
out.push({
843877
kind: 'unmapped_column',

0 commit comments

Comments
 (0)