Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on - #11430
Conversation
…index keys on MySQL refuses a TEXT/BLOB column in a key without a prefix length, so CREATE TABLE succeeded while ALTER TABLE ... ADD [UNIQUE] INDEX failed and the object stayed registered-but-broken. Measured on MySQL 8.0.46: 36 of 44 platform objects failed schema-sync this way; honouring the field's own declared maxLength takes that to 12. The bound is the field's own maxLength -- nothing is invented. schema-drift already treats varchar(field.maxLength) as the expected physical shape (its widen_varchar / narrow_varchar ops say so); this is the emitter agreeing with the differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
…enforcement change) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 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 978ab69c4d7f7a7d3f4fd82a779578d0d21d5a77 && git checkout 978ab69c4d7f7a7d3f4fd82a779578d0d21d5a77
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cccbe51bf7b0cde74e208f086d9593d3983c4238 0717830f5d4c6b6dade3a1a0f258b14f2ce10384 && git checkout -B drift-repro cccbe51bf7b0cde74e208f086d9593d3983c4238 && git merge --no-ff 0717830f5d4c6b6dade3a1a0f258b14f2ce10384
node scripts/docs-audit/affected-docs.mjs --json cccbe51bf7b0cde74e208f086d9593d3983c4238
|
os-zhuang
commented
Aug 23, 2026
PM note (engine seat) — CI still running; this is not the accept. Two corrections owed first, one of them mine. ⛔ My dispatch order stated a fact about prefix indexes that is WRONG. Correcting it here, where it was acted on.I wrote, as the trap to watch for:
Measured on live MySQL 8.0.46, that is backwards. It enforces uniqueness of the prefix — so it is stricter than the declared constraint and rejects genuinely different values, rather than admitting duplicates. On The conclusion I asked for is unchanged — prefix indexes are disqualified for This is the third claim of mine that measurement has overturned today (a region read from PR prose, a stale "the database is running", and now this). The pattern is the same each time: I stated something inherited or reasoned as though it were measured. The instruction to measure rather than reason is doing more work than my instructions are. The card's own premise was also only partly right — and that reframes the fixThe title says unbounded string fields become TEXT. Measured: 78 of the 94 indexed text columns already declare a Three independent things already agreed with that shape, and the PR found all three rather than asserting the choice:
The Applying it on all dialects rather than Stopping at 12 was correct36/44 → 12/44 failing, indexes present 23/128 → 89/128. The remainder is three groups that need decisions about platform-object field declarations, not about the driver: 7 unbounded UNIQUE identity columns carrying better-auth and external-IdP values, 3 "Guessing any of these would be writing speculative code into identity tables" is the right instinct, and Generated by Claude Code |
…clared index keys it The Type Conversion Matrix claimed `text` -> TEXT unconditionally on all three dialects, and the `text` section's prose said outright that "`maxLength` is enforced by record validation, not by the column type -- the DDL does not read it". Both are falsified by the keyed-text mapping. Corrected in three places rather than one: the matrix row (with a footnote, since the condition does not fit a cell), the `text` section's prose plus a callout explaining why the bound follows the index, and the `textarea` / `html` sections which carried the same unconditional claim and which the drift bot -- anchoring on symbols, not prose -- did not flag. Both halves of the condition are stated wherever the claim appears: a declared maxLength of 768 or less AND a declared index keying the column. A reader taking away only "bounded text becomes varchar" would be surprised by a non-indexed maxLength: 200 field staying TEXT. Measured, not assumed: the other VARCHAR(255) rows (email/url/phone, select/radio, lookup/master_detail/tree, autonumber) are unchanged even when keyed and even when declaring a maxLength, so those rows stay as they were. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
Uh oh!
There was an error while loading. Please reload this page.
Part of #11374 — the mapping half. Deliberately not a closing reference: 12 of the 44 platform objects still fail schema-sync on MySQL, and the remainder needs a decision this PR does not take (see What is left).
The defect
createColumnmapped the whole text family (text/textarea/html/markdown) to an unboundedTEXT, ignoring the field's own declaredmaxLength. MySQL refuses aTEXT/BLOBcolumn in a key without a prefix length, and the two halves of schema-sync fail separately: theCREATE TABLEsucceeds, thenALTER TABLE … ADD [UNIQUE] INDEXfails withER_BLOB_KEY_WITHOUT_LENGTH. The table lands on disk without the constraint it declared and the object stays registered-but-broken.Reproduced on a live MySQL 8.0.46 (utf8mb4, InnoDB DYNAMIC,
STRICT_TRANS_TABLES), feeding all 44 platform objects throughsyncSchema:syncSchemaThe shape, and why it is this one
The premise in the card's title is only partly right. The columns are not mostly unbounded — 78 of the 94 indexed text columns across platform objects already declare a
maxLength(sys_user.phone_numberdeclaresmaxLength: 32). The driver simply never read it. Only 16 are genuinely unbounded.So the fix is not "impose a length where the spec declared none" — it is honour the bound the field already declared. Three things already agreed on that shape before this PR:
schema-drift.tstreatsvarchar(field.maxLength)as the expected physical shape of a bounded field — itswiden_varchar/narrow_varcharops say so in as many words. This is the emitter finally agreeing with the differ.columnInfo()reportsmaxLength: 65535for aTEXTcolumn, so every bounded text field already reported a permanent destructivenarrow_varchardrift ("metadata caps at 32 chars but the column allows 65535") against a column the driver itself had created. That finding is now gone for new tables.Field.stringhas always taken knex'svarchar(255). A bounded text field is now less arbitrary than its string sibling, not more.Why not a prefix index — measured, not reasoned
The card flagged the uniqueness gap as the trap, and the measurement inverts its direction. The expectation was that a prefix-unique index "silently accepts two values that differ only past the prefix". It does the opposite: it enforces uniqueness of the prefix, so it is stricter than the declared constraint and rejects two genuinely different values that share one.
On MySQL 8.0.46,
UNIQUE KEY (token(191))with two distinct 200+ character tokens sharing their first 191 characters:A true duplicate is still caught, so the index is not broken — it is a different constraint. On
sys_session.tokenthat is a valid sign-in refused with a duplicate-key error, data-dependent and silent. Disqualified forUNIQUE, and not adopted for ordinary indexes either, so the model stays one rule rather than two.Scope — both bounds are load-bearing
Field.text({ maxLength: 65000 })staysTEXT:varchar(65000)on utf8mb4 is 260000 bytes and blows MySQL's 65535-byte row limit, turning a working table into an un-creatable one.maxLengthabsent, or wider than 768 characters, leaves the columnTEXT. 768 is measured, not read off a doc page:varchar(768) UNIQUEcreates,varchar(769) UNIQUEis refused withER_TOO_LONG_KEY: max key length is 3072 bytes.isMysql. The alternative is one declaration with two enforcement answers — the same app refusing an over-length write on MySQL and accepting it on Postgres. Existing tables are untouched: schema-sync is additive and never rewrites a column already present, and on Postgres atextcolumn reportsmaxLength: null, so the differ's varchar rule stays silent and no migration is provoked.minor, and the break is named: a keyed bounded text column now enforces its declared length where the dialect enforcesvarchar, so a write longer thanmaxLengththat previously landed in an unboundedTEXTis refused (ER_DATA_TOO_LONGunder strict mode). That is a previously-inert declaration becoming enforced, and it is what makes the column indexable — but it is a behaviour change.When the column still cannot be keyed
The index is refused loudly, with a message naming the columns at fault and the declaration that fixes them, rather than silently substituting a weaker or different constraint. The boot still fails exactly where it failed before; it just says why.
The coverage hole
packages/drivers/driver-sql/src/sql-driver-keyed-text-mysql.test.ts— no CI change needed. The requiredTemporal Conformance (live PG + MySQL)job already runspnpm --filter @objectstack/driver-sql testwithOS_TEST_MYSQL_URLset, and the per-file database ledger picks up a new*.test.tsin that directory automatically. The hole was never the job — it was that every suite in this package builds its tables with an explicitknex.string()(VARCHAR), so the live MySQL never met the TEXT mapping.Six pins: the
varchar(maxLength)emission and the two "stays TEXT" corners (SQLite, so Test Core carries them too); on live MySQL, that the declared indexes physically exist and are full-value, not prefixed (information_schema.statistics.sub_part IS NULL); that an unkeyable column is refused by name and leaves no substituted index; and the prefix-unique measurement itself, kept executable so the rejected route cannot be re-argued from intuition.Reverse-verified. Reverting only the mapping (
col = table.text(name)) reds 2 of the 6 — the SQLite emission pin and the live-MySQL index pin — and leaves the other 4 green, which is the correct direction. Mutation confirmed on disk before the run (anchor hit count 1, injected-text count 1 → 0,git diff --stat2 insertions / 2 deletions); restore ran from anEXIT INT TERMtrap. No rebuild was involved and none was needed: the suite imports../src/index.js, and the red appeared with no build step, which is itself the proof it reads source rather thandist/.Docs corrected in the same PR
content/docs/protocol/objectql/types.mdx, in three places — the drift bot flagged one, and the other two were the same claim in prose, which it could not see because it anchors on symbols:| text | TEXT | TEXT | TEXT |→ the row now coverstext/textarea/htmland carries a*to a footnote, since the condition does not fit a cell.textsection's prose, which asserted the opposite of the new behaviour outright: "maxLengthis enforced by record validation, not by the column type — the DDL does not read it." Replaced, plus a callout explaining why the bound follows the index and what happens when a keyed column cannot be bounded.textareaandhtmlsections, which both carried a bareSQL driver: TEXT.Both halves of the condition are stated wherever the claim appears — a declared
maxLengthof 768 or less and a declared index keying the column — because a reader who takes away only "bounded text becomes varchar" would be surprised by a non-indexedmaxLength: 200field stayingTEXT. Thecompany_nameexample already on that page is exactly such a field, so the prose names it.The other
VARCHAR(255)rows were checked by measurement, not assumption, as asked:email/url/phone,select/radio,lookup/master_detail/treeandautonumberall still emitvarchar(255)even when keyed and even when declaring amaxLength— only the text family moved, so those rows stand. (Separately, and pre-existing: that they ignoremaxLengthat all is the defect filed as #11431 — not touched here.)⛔
content/docs/releases/is not touched. The drift bot also listedreleases/v17.mdx; that page is release-owned, and I have flagged it in the report rather than editing it.What is left, and why this PR stops here
12 objects still fail, in three groups that all need a decision about platform-object field declarations, not about the driver:
UNIQUEidentity columns —sys_account.{provider_id,account_id,issuer},sys_api_key.key,sys_device_code.{device_code,user_code},sys_session.token. These hold values produced by better-auth and by external IdPs (an OIDCsub, an issuer URL), so choosing a bound is a product decision with a real breakage mode.maxLength: 1024UNIQUEtoken columns —sys_oauth_access_token.token,sys_oauth_refresh_token.token,sys_oauth_resource.identifier. Bounded, but past the 768-character key ceiling. A full-value unique on a value that may legitimately be a multi-KB JWT is not expressible on utf8mb4 InnoDB at all; the options (anasciicharset on the column, a hashed shadow key like this driver's own_objectstack_sequences.key_hash, or a narrower declared bound) are architectural.sys_metadata's 4-column unique is 3460 bytes against a 3072-byte ceiling even with every part bounded.Guessing any of these would be writing speculative code into identity tables. They are reported for triage instead.
Verification
All at
0717830f5d, against live servers configured for CI parity (MySQL@@global.time_zone='+08:00', Postgrestimezone='Asia/Shanghai', processTZ=America/New_York) — both were down on arrival and started for this work.pnpm --filter @objectstack/driver-sql testagainst live PG + live MySQL — 125 files / 2511 tests passed.pnpm --filter @objectstack/driver-sql typecheck— clean.node scripts/pm/dispatch-gates.mjs(no paths passed): the docs path grew it from 13 to 30 path-matched families. All 30 exit 0, plus the convention-triggered families, pluscheck:type-check-debt --re-measurere-run on this head ("33 ledger entr(ies) re-measured … 1897 raw tsc error(s) total, none above its recorded number").pnpm lint(full repo,eslint . --no-inline-config) — exit 0.Generated by Claude Code