Uh oh!
There was an error while loading. Please reload this page.
fix(platform-objects): make the last two non-unique keyed text indexes expressible on MySQL - #12314
Conversation
…s expressible on MySQL A non-unique index over a text column MySQL cannot key is refused outright (ER_BLOB_KEY_WITHOUT_LENGTH), failing the whole object's syncSchema and leaving it registered with its declared index absent. #11627's hash shadow closed the UNIQUE half of this class; it structurally cannot serve the non-unique half, because an index over a digest accelerates no WHERE col = ? the planner can reach. Two members remained, ruled separately by the maintainer on 2026-08-25: - sys_verification.value: the declared index is REMOVED. The column is genuinely unboundable (oauth-provider writes OIDC authorization-code payloads there as a JSON blob) and the index was measured dead — better-auth 1.7.1 keys verification lookups on identifier/id/expiresAt, upstream declares the field unindexed and unbounded, and no in-repo query filters by value. - sys_oauth_client_resource.resource_id: the declared bound narrows 1024 -> 768. This is a live access path (FK side of sys_oauth_resource.identifier), so it keeps its index and becomes keyable instead. Narrowing below the referent's 1024 is safe because upstream better-auth stores that same identifier as varchar(255) on MySQL and this referring column as varchar(36), so nothing the producing contract can emit lives in the discarded band. The package pin gains the executable form of "the class is closed": it now rejects a non-unique index over any text column MySQL cannot key, enumerated across the whole package rather than naming these two. Its UNBOUNDABLE allowlist, which existed to excuse sys_verification.value, moves with the change and is empty; a synthetic control keeps its excusing branch exercised. Part of #11701 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 2 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 159c15fd44803130aba1f4d02847b4feb58d6a4c && git checkout 159c15fd44803130aba1f4d02847b4feb58d6a4c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a dab5649c7e96ecd48492cbdba2dce483fc54fe3e && git checkout -B drift-repro 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a && git merge --no-ff dab5649c7e96ecd48492cbdba2dce483fc54fe3e
node scripts/docs-audit/affected-docs.mjs --json 5ce5f8c12479f40d548b9f3ba9f8725c14c9bf9a |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11701
Implements both halves of the maintainer's 2026-08-25 ruling on this card. #12198 closed the 5 UNIQUE members of the >768-char keyed-text class by hash shadow (MySQL 7 → 2 failing objects); these are the 2 NON-UNIQUE remainders, which a hash shadow structurally cannot serve — a UNIQUE constraint is an equality-only predicate that survives hashing exactly, but a non-unique index exists for an access path, and an index over a digest accelerates no
WHERE col = ?the planner can reach.The two are ruled separately, for different reasons, and this PR keeps them apart.
1.
sys_verification.value— the declared index is removedThe column is genuinely unboundable (better-auth's oauth-provider writes OIDC authorization-code payloads there as a JSON blob), so no bound rescues it and no shadow carries it. The index was measured dead, re-verified here rather than carried forward from the card:
identifier,id, orexpiresAt—internal-adapter.mjs'sfindByIdentifier/consumeByIdentifier; agrepforfield: "value"acrossbetter-authand@better-auth/oauth-providerreturns nothing.sys_verificationbyvalue.A measured consequence the card did not anticipate: removing this index did not merely delete an index — it restored two live ones. Because
initObjectsaborted at the[value]index,sys_verificationon MySQL previously carried onlyPRIMARY. Read from the catalog, before vs after:sys_verificationindexes (MySQL)PRIMARYonlyPRIMARY,idx_sys_verification_identifier,idx_sys_verification_expires_atThe dead index was suppressing the object's two real access paths, including the one better-auth actually queries on.
2.
sys_oauth_client_resource.resource_id— the declared bound narrows 1024 → 768sys_oauth_resource.identifier, declared 1024) needs evidence that no legitimate resource identifier exceeds 768 characters. Measured, not assumed:@better-auth/oauth-provider1.7.1 declaresoauthClientResource.resourceIdasreferences: { model: "oauthResource", field: "identifier" }— so the column really does hold the identifier itself, not an opaque id.managedBy: 'better-auth',protection.lock: 'full'), and its own MySQL migration generator (better-auth/dist/db/get-migration.mjs,getType) emits the referentoauthResource.identifierasvarchar(255)(thefield.uniquebranch) and this referring column asvarchar(36)(thefield.referencesbranch). A resource whose identifier exceeded 768 characters could never have been registered upstream in the first place.So the discarded (768, 1024] band holds nothing the producing contract can emit, and route A is available. 768 rather than upstream's 255 deliberately: it is the smallest narrowing that makes the index expressible, so it rejects the least of the referent's declared domain — guessing a tighter number to make a key fit is what
sys_account.issuerexplicitly refuses to do.The index is kept because it is a live access path: upstream reads it as a predicate (
findOne({ clientId, resourceId })on the client-registration collision path). That is why "just remove the index" was not taken here.maxLength: 1024cites no producer and now over-declares relative to every column referencing it. Out of scope and not touched — narrowing it would also movesys_oauth_resourceoff #12198's hash shadow. Filed unassigned as #12313.Measured on live servers — clause ②
Live MySQL 8.0.46 (utf8mb4 / InnoDB /
STRICT_TRANS_TABLES) and PostgreSQL 16.13, every distinct exported platform object driven throughinitObjectsone at a time. Every physical claim is read back frominformation_schema.COLUMNS/.STATISTICSin a separate query — never from the DDL this change causes to be emitted.syncSchemaThe 2 reproduce #12198's stated remainder exactly, by name and error code —
sys_oauth_client_resourceandsys_verification, bothER_BLOB_KEY_WITHOUT_LENGTH. (Population is 45 rather than #12198's 44 becausesys_metadata_activationlanded in between, via #12185; both legs here use the same enumeration, so the delta is unaffected.)Catalog readings
sys_oauth_client_resource.resource_idtext(65535)varchar(768)idx_sys_oauth_client_resource_resource_idNON_UNIQUE=1,SUB_PART = NULLsys_verification.valuetext(65535)text(65535) — unchangedidx_sys_verification_valueSUB_PART = NULLis load-bearing: it is what distinguishes a whole key part from the prefix index the ruling rejected, which would report a sub-part.Accept/reject transitions, boundary included
resource_idat 768 charsstoredLength = 768resource_idat 769 charsER_DATA_TOO_LONGsys_verification.valueat 5000 charsstoredLength = 5000The last row is the control that this change narrowed nothing: removing the index left the column as wide as it ever was.
The pin moved rather than being deleted
sys_verification.valuewas the sole entry in theUNBOUNDABLEallowlist inplatform-keyed-text-bounds.test.ts, with a reason explaining why the column could not be bounded. The ruling did not bound it — it removed the index — and an unindexed column is not a keyed column, so that entry stopped describing anything and would have gone red by name on the allowlist's own rot check. It is removed, with the comment above the now-empty list recording what emptied it.Emptying an allowlist is a silent weakening, so two things replace it:
unboundedKeyedColumns(objects, allowlist)and a synthetic control drives both of its outcomes, so the excusing branch the real objects no longer reach stays exercised rather than rotting.#11701pin is the executable form of "the class is closed": it enumerates the whole package and rejects any non-unique index over a text column MySQL cannot key. It does not name these two columns in the rule — a third member arriving later fails at test time instead of on a live server months on. Its positive control pins both dispositions by name (not.toContain('sys_verification.value'),toContain('sys_oauth_client_resource.resource_id')) and asserts the enumeration still sees ≥50 columns (measured: 55).Verification, at
dab5649c7eGate union derived, not recalled —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived after the changeset existed: 13 path-matched + 8 convention-triggered families. All run, every exit code captured before any pipe, all green. Quoting each gate's own verdict line where it decides something:check-adr-0087-registration: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)" — this is what settles whether removing a declared index and narrowing a declared bound are declared-breaking; the gate ruled, not an assumption.check-type-check-coverage --re-measure: "32 ledger entr(ies) re-measured in 238.0s, 1843 raw tsc error(s) total, none above its recorded number … surplus: none", on a fully built workspace closure (70/70 turbo tasks).check-nul-bytes: "scanned 6803 text file(s) … no raw ASCII control bytes".check-i18n: "OK (9 package(s) — all bundles in sync)";check-i18n-stale-fill: "0 stale-fill leaf/leaves".pnpm lint(eslint . --no-inline-config), whole repo: exit 0 — run in full, so no narrowing is claimed.@objectstack/platform-objects:typecheckexit 0;test493 passed (493).Ablation — direction predicted in writing first
Predicted before running: reverting only the two object definitions to the branch point must turn the new pin red, 3 of 7 tests, naming exactly
sys_verification.value (maxLength: undefined)andsys_oauth_client_resource.resource_id (maxLength: 1024).Measured: 3 failed | 4 passed, the same three by name, offender lists exactly as predicted. The 4 that stayed green include the allowlist rot check — vacuously, because the list is now empty, which is precisely the hole the synthetic control was added to fill.
Mutation proven on disk by single-line anchored counts read before any test result (
value-index 0→1,maxLength: 10240→1,maxLength: 7681→0), and the restore leg re-verified the same way back to 0/0/1 withgit statusempty. The mutation script carried atrap … EXIT INT TERMrestore. No rebuild was owed and none was done: the test imports its subject asimport * as PlatformObjects from './index'— a relative specifier vitest resolves tosrc/index.ts, so nodistparticipates in either leg.os-verify-lock.shreportedVERDICT command-exit 0for the ablation batch while the vitest inside it exited 1 — the #12288 behaviour. The verdict read here is the script's ownABLATION-TEST-EXIT=1.Generated by Claude Code
Generated by Claude Code