Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): carry an over-long UNIQUE index on a hash-shadow column (MySQL utf8mb4) - #12198
Conversation
…umn (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
#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
…inary 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
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d03e13826464191eb5bf071100e33d5b2c689465 && git checkout d03e13826464191eb5bf071100e33d5b2c689465
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 494279cb31f1d92adab959763085e19c923a8652 686ffedf79af1d05f83cacb3dcae619b2d1f91d8 && git checkout -B drift-repro 494279cb31f1d92adab959763085e19c923a8652 && git merge --no-ff 686ffedf79af1d05f83cacb3dcae619b2d1f91d8
node scripts/docs-audit/affected-docs.mjs --json 494279cb31f1d92adab959763085e19c923a8652
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11627
Part of #11701 — see "What #11701 keeps open", below. That sub-issue does not close here.
Implements the C half of the maintainer's 2026-08-24 ruling on #11374 (verbatim 「四维分析一致的,接手你的建议。」 — A + C(hash), B rejected). A is landed and closed; this is the hash-shadow key.
The defect
On MySQL utf8mb4 InnoDB a key part holds at most 3072 bytes — 768 characters — so a full-value UNIQUE index over a longer column is inexpressible, not merely expensive. An OAuth access token may legitimately be a multi-KB JWT, so no declared bound rescues these columns.
syncSchemarefused the object outright, leaving it registered with its declared uniqueness absent.Population — re-measured, not carried forward
Measured through this driver against live MySQL 8.0.46 (utf8mb4/InnoDB) and PostgreSQL 16.13, over all 45 platform-object exports (44 distinct —
sys_metadatais exported twice):d651ac3c32)syncSchemaER_BLOB_KEY_WITHOUT_LENGTH+ 1ER_TOO_LONG_KEY)The 7 reproduce the PM's independently-verified count exactly. What that count did not capture is that the population is not homogeneous — it splits by index kind, and that split decides what is fixable here:
5 UNIQUE — fixed by this PR (all four of #11627's ruled cases, plus #11701's
sys_account):sys_oauth_access_token.token(1024) ·sys_oauth_refresh_token.token(1024) ·sys_oauth_resource.identifier(1024)sys_metadataidx_sys_metadata_overlay_active— the 4-column composite (type, name, organization_id, package_id), theER_TOO_LONG_KEYat 3460 bytessys_account[issuer, account_id]—issuerat 2048 (#11627 population re-measured after the A half: three additional members of the >768/unboundable keyed-text class (resource_id 1024, sys_verification [value], sys_account [issuer+account_id]) #11701 item 3)2 NON-UNIQUE — deliberately still refused (#11701 items 1 and 2):
sys_oauth_client_resource.resource_id,sys_verification.value.The route
A UNIQUE index MySQL refuses is carried by a driver-owned shadow column named after the index —
uniq_sys_oauth_access_token_token__hash, for example. It is aSTORED GENERATEDVARBINARY(32)holdingUNHEX(SHA2(key, 256))over the index's key columns, and the unique index moves onto it.A generated column rather than an application-computed one (the
_objectstack_sequences.key_hashprecedent hashes in app code because it is a cross-dialect PK). Three properties app hashing cannot buy: every writer maintains it — includingos migrate, a DBA, replication; existing rows are hashed by theALTERitself, so there is no backfill that could partially complete; and nothing can write a shadow that disagrees with its source columns.The exact expression, as the catalog reports it back for the 4-column composite:
Selected by the server's own error code, after the direct index is refused — never by a dialect check. A pre-flight would have to reproduce MySQL's 3072-byte arithmetic and, wrong in the strict direction, would move an object onto a shadow key on a server that would have taken the real index. Postgres and SQLite never refuse, so they are byte-identical to before (verified: 0 shadow columns on Postgres).
Measured semantics —
information_schema, never the emitted DDLEvery physical claim is read back in a separate catalog query.
SUB_PART IS NULLis the load-bearing one: it is what distinguishes this from the rejected prefix index, which reports a sub-part.sys_session.tokensign-in refused as a duplicate).SHA2(NULL)is NULL, matching a direct UNIQUE.CONCAT(a, 0x1f, b, …);CONCATreturning NULL for any NULL argument is MySQL's composite-UNIQUE semantics. The separator keeps the encoding injective (('xy','')≠('x','y')).Boundary, from the catalog
maxLengthvarchar(767)vvarchar(768)v— last direct widthtextvarbinary(32)shadow — first shadow widthtextCollision bound (clause ②)
CHARACTER_MAXIMUM_LENGTH = 32, and a test compares the stored bytes against Node'screateHash('sha256')digest.Is a write-time collision distinguishable from a genuine uniqueness violation? From the server error alone, no. Both are
ER_DUP_ENTRYon the same shadow index, and MySQL quotes the raw digest, not the value — measured:Duplicate entry '\xA0\x02\x13\xC1…' for key 'proto.uniq_token'. An operator reading that has been told nothing — a user-visible wrong answer, not a crash.So the driver resolves it with one read on the failure path: re-select by the source columns. A matching row is a real duplicate, reported in the declared terms (constraint + source columns). No matching row, with the shadow still conflicting, is a collision — named as such, with the values, and asked to be reported.
createonly.updateissues through three paths with no shared catch, and every flow here that writes these columns inserts. An UPDATE that collides still fails correctly; it just still reports the binary digest. Named in the docblock rather than left to be discovered.Drift
The differ learns the shadow is driver-owned (
isHashShadowColumn). Without it the orphan pass reports the shadow asunmapped_columnand proposesdrop_column— which would take the UNIQUE index with it and silently return the object to "registered, uniqueness unenforced" via the migration tool. It adds no new drift op, soapplyMigrationEntries' applied/skipped partition (#11722) is untouched.What #11701 keeps open — and why this is a decision, not an omission
A hash shadow answers uniqueness, which is an equality-only predicate. A non-unique index exists for an access path, and an index over a digest serves no lookup the planner can reach:
WHERE col = ?cannot use it without rewriting the read side to filter on the digest too. Creating one would trade a loud refusal for a table that syncs, costs a write on every row, and accelerates nothing.#11701 itself asked for a liveness measurement before adding a shadow column there. That measurement is now made, and
sys_verification.valuelooks removable rather than shadowable —sys-verification.object.tsstates in its own index comment that "better-auth keys verification lookups onidentifier, notvalue". But removing a declared index is a contract change, so it is escalated rather than guessed.sys_oauth_client_resource.resource_idneeds the same call.sys_verification.valuetherefore stays in theUNBOUNDABLEallowlist inplatform-keyed-text-bounds.test.ts, and its reason (which tracks the debt to #11627) remains accurate — this route does not make it boundable.Verification
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackatd651ac3c32— 14 path-matched + 6 convention-triggered families, all run, all green, pluscheck:type-check-debt --re-measureon a built workspace closure ("surplus: none — every entry sits exactly at its measurement").pnpm lint(eslint . --no-inline-config) — full repo, exit 0.@objectstack/driver-sql: 141/141 files, 2851 passed, 1 skipped, with live MySQL + Postgres attached andTZ=America/New_York(the matrix's own three-way zone-skew guard).@objectstack/specpackage-name rules), so no rebuild was owed.sub_part.Generated by Claude Code