Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): name the declarations behind MySQL's row-size refusal - #11792
Conversation
MySQL charges every bounded column's DECLARED byte width against a per-row budget, independently of the per-column varchar ceiling, and refuses the CREATE naming no column and no declaration — about a table its author described entirely in metadata. Translate ER_TOO_BIG_ROWSIZE at the initObjects create/alter call sites into a refusal that names every contributing field, its emitted varchar width and its byte cost at the schema's real bytes-per-character. The same failure, re-worded: a translator cannot over-refuse by construction, where a pre-flight reproducing the server's arithmetic can. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 0365471e0a260df1dc52d8473bad328b87d5199c && git checkout 0365471e0a260df1dc52d8473bad328b87d5199c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 56630b7eee0854b2569fe2579e00c43210441fd6 a483faedd593b13195f24ff2b8f673fb7584185c && git checkout -B drift-repro 56630b7eee0854b2569fe2579e00c43210441fd6 && git merge --no-ff a483faedd593b13195f24ff2b8f673fb7584185c
node scripts/docs-audit/affected-docs.mjs --json 56630b7eee0854b2569fe2579e00c43210441fd6
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11565
MySQL charges every bounded column's declared byte width against a per-row budget, independently of the per-column
varcharceiling. An object whose fields declare enough total width simply failsCREATE TABLE— and the server's refusal names no column and no declaration. It says "You have to change some columns to TEXT or BLOBs" about a table its author described entirely in metadata. Sixteen fields atmaxLength: 1024is not an exotic object, and an AI authoring a metadata app has no reason to suspect a cumulative limit exists.Schema sync now translates
ER_TOO_BIG_ROWSIZEat both DDL sites into the same failure, re-worded: every varchar column the object produces, widest first, with its emitted width and its byte cost at the schema's real bytes-per-character.The shape decision: a translator, not a pre-flight
The card left this open and explicitly was "not prescribing", while arguing pre-flight was the better shape because a post-hoc translator "only knows the table failed". That premise does not survive contact with the call site. The catch sits inside
initObjects' own loop, whereobj.fieldsand the resolvedkeyedColumnsare in scope — so the translator names every contributing field, exactly as a pre-flight would. The advantage claimed for the pre-flight is not an advantage it has here.What the two shapes do not share is their failure direction:
Four measurements taken while choosing (live MySQL 8.0.46, utf8mb4 / InnoDB / DYNAMIC / 16K pages) show the arithmetic is genuinely hard to hold, and that its inputs are properties of the server, not of the metadata:
64 x varchar(1024)is refused on a utf8mb4 database and CREATES on a latin1 onevarchar(63)costs 253 bytes,varchar(64)costs 258163 x varchar(100)is refused nullable and CREATES asNOT NULL40 x maxLength 63through this driver is refused withRow size too large (> 8126)— InnoDB's per-page limit, at about a sixth of the 65535 budgetMeasurement 4 is the decisive one. It is reached by a completely ordinary object, it is invisible to the model a pre-flight would implement, and it arrives under the same
ER_TOO_BIG_ROWSIZEcode — so the translator handles it for free and reports the number the server quoted rather than asserting 65535 over it.One more consequence worth naming given this round: a pre-flight would need a dialect predicate to know it is on MySQL. The translator needs none —
ER_TOO_BIG_ROWSIZEis a MySQL code, so the diagnostic is MySQL-scoped by construction. The diff therefore never approaches theisMysqlgetters that #11550 owns this round.⛔ Nothing is refused that was accepted before, and no other dialect is touched. The dispatch tier is unchanged.
What landed
packages/drivers/driver-sql/src/sql-driver.ts, all in the diagnostic neighbourhood besideexplainUnkeyableTextColumn(#11374), whose idiom this matches — catch, recognisecode, name the declarations, re-throw the same failure:explainRowSizeOverflow()— the translator. Carries the four measurements above in its docblock.varcharColumnChars()— a read-only mirror ofcreateColumn's width decision.createColumnitself is untouched.rowWidthProfile()— the offender list, widest first, with per-column byte cost.varcharPackLength(),schemaBytesPerChar()— the arithmetic and the failure-path-only charset read.rethrowWithRowSizeExplanation()— wired into theCREATE TABLEandALTER TABLE ADD COLUMNcalls ininitObjects.The
ALTER TABLEsite is included deliberately, and is named here rather than left to be noticed: it is the same defect at the sibling call site twenty lines below, and it is the more reachable half in a living app — a field added to an object that was already near the budget is refused by the width of columns nobody is touching, with the same column-less server error. One shared helper, two catches.Two things the diagnostic says that a naive reading of the card would have missed:
maxLengthstill costs 1022 bytes.lookup,user,auto_numberand the option types all taketable.string(name)= knex'svarchar(255). An object reaches this budget having declared nothing at all — pinned live with 64 barelookupfields — and a diagnostic that only read declared bounds would name nothing on that shape.Verification
Measured through the driver, so its built-in
id varchar(255)is inside the numbers — which shifts the card's raw-SQL second row:63fields atmaxLength: 255create,64are refused (raw SQL was 64/65; theidcolumn eats one field's worth). ThemaxLength: 1024row is unchanged at 15/16.packages/drivers/driver-sql/src/sql-driver-11565-row-byte-budget.test.ts— 10 tests, both halves:FieldType.options-driven pin that the width mirror agrees withcreateColumnfor every field type the spec declares (a type added to the spec joins the pin automatically); the offender-list ordering; and — the half that stops "refuses" from passing for "refuses the right objects" — that the 16-field object still creates cleanly on SQLite, which has no such budget.ALTER TABLE ADD COLUMNpath; the InnoDB page-limit variant; and the nothing-declared shape.Reverse verification (implementation reverted to
origin/main, pin kept; mutation confirmed on disk by marker count 4 → 0 before running, restored under anEXIT/INT/TERMtrap): 9 of 10 red. The one that stayed green is exactly the no-over-refusal pin — it asserts unchanged behaviour and must be green in both directions. No rebuild is involved: the pin imports../src/index.js, which vitest resolves to source, so nodist/can go stale under it. The pre-fix failure text is the defect verbatim — 4 000+ characters of DDL followed by one sentence naming no column.Gate union run at
a483faedd5on a clean worktree, after the final commit:pnpm lint(repo-wideeslint . --no-inline-config)pnpm --filter @objectstack/driver-sql typechecktsc --noEmit, cleanpnpm --filter @objectstack/driver-sqlfull suite, live MySQL +TZ=America/New_YorkTest Files 129 passed | 4 skipped (133),Tests 2317 passed | 56 skipped (2373)check:driver-conformanceOK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt— identical before and aftercheck:nul-bytesOK (scanned 6570 text file(s) … no raw ASCII control bytes)check:type-check-coverageOK — 65/78 workspace packages type-checked … 13 in the DEBT ledgercheck:engine-double-contractOK — 401 pinned, 133 in the DEBT ledger, 2 exemptcheck:test-source-aliasOK — 72 packages with tests scannedcheck:type-source-resolutionOK — 77 packages with a tsconfig.json scannedcheck:live-db-isolation,check:cross-package-test-inputs,check:where-matcher,check:query-options-erasure,check:slot-lookup,check:published-files,check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-plugin-teardown-shape,docs-audit/check-affected-docs,release-rehearsal-clone --self-testGate list derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the real change set, not from the dispatch word.One declared narrowing:
check:type-check-debt(--re-measure) was not run locally. It re-runstscper ledger entry, and@objectstack/driver-sqlis in neither ledger — it carries notest-typecheck-debt.jsonand its name appears incheck-type-check-coverage.mjsonly in prose. Itstsconfig.jsonisinclude: ["src/**/*"]with no test exclusion, so the new pin is inside the programpnpm --filter @objectstack/driver-sql typecheckran green over, and the structural half of the gate ran green here. CI runs the ratchet regardless.Live MySQL was down in the container; started at CI parity (
@@global.time_zone='+08:00', processTZ=America/New_York), other agents' databases left alone.Generated by Claude Code
Generated by Claude Code