Skip to content

fix(driver-sql): name the declarations behind MySQL's row-size refusal - #11792

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-11565-mysql-row-budget-preflight
Aug 24, 2026
Merged

fix(driver-sql): name the declarations behind MySQL's row-size refusal#11792
huangyiirene merged 2 commits into
mainfrom
claude/issue-11565-mysql-row-budget-preflight

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#11565

MySQL charges every bounded column's declared byte width against a per-row budget, independently of the per-column varchar ceiling. An object whose fields declare enough total width simply fails CREATE 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 at maxLength: 1024 is 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_ROWSIZE at 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, where obj.fields and the resolved keyedColumns are 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:

  • A pre-flight must reproduce the server's arithmetic. Wrong in the lax direction it is merely useless — the server still refuses, with today's bad message. Wrong in the strict direction it refuses an object MySQL would have accepted, which is a contract change.
  • A translator cannot over-refuse by construction. It speaks only after the server has already refused, so no arithmetic error it makes can change which objects are accepted.

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:

#MeasurementWhy a pre-flight is exposed
164 x varchar(1024) is refused on a utf8mb4 database and CREATES on a latin1 onethe charset multiplier is the schema's; hardcoding 4 bytes/char over-refuses by 4x on latin1
2utf8mb4 varchar(63) costs 253 bytes, varchar(64) costs 258the length prefix moves at a 255-byte payload boundary, not a character one
3163 x varchar(100) is refused nullable and CREATES as NOT NULLthe null bitmap counts toward the budget; identical declared widths, different verdict
4A second, independent limit: 40 x maxLength 63 through this driver is refused with Row size too large (> 8126) — InnoDB's per-page limit, at about a sixth of the 65535 budgetsame error code, different number; a 65535-byte pre-flight waves this through and leaves the author with exactly the unactionable error this card is about

Measurement 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_ROWSIZE code — 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 noneER_TOO_BIG_ROWSIZE is a MySQL code, so the diagnostic is MySQL-scoped by construction. The diff therefore never approaches the isMysql getters 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 beside explainUnkeyableTextColumn (#11374), whose idiom this matches — catch, recognise code, name the declarations, re-throw the same failure:

  • explainRowSizeOverflow() — the translator. Carries the four measurements above in its docblock.
  • varcharColumnChars() — a read-only mirror of createColumn's width decision. createColumn itself 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 the CREATE TABLE and ALTER TABLE ADD COLUMN calls in initObjects.

The ALTER TABLE site 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:

  • A field declaring no maxLength still costs 1022 bytes.lookup, user, auto_number and the option types all take table.string(name) = knex's varchar(255). An object reaches this budget having declared nothing at all — pinned live with 64 bare lookup fields — and a diagnostic that only read declared bounds would name nothing on that shape.
  • The InnoDB page-limit variant is reported with the server's own number, not with 65535.

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: 63 fields at maxLength: 255 create, 64 are refused (raw SQL was 64/65; the id column eats one field's worth). The maxLength: 1024 row is unchanged at 15/16.

packages/drivers/driver-sql/src/sql-driver-11565-row-byte-budget.test.ts — 10 tests, both halves:

  • Dialect-free (runs in Test Core): the pack-length arithmetic; a FieldType.options-driven pin that the width mirror agrees with createColumn for 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.
  • Live MySQL: a charset non-vacuity assertion (the boundaries below are utf8mb4's); both sides of both measured boundaries (15 creates / 16 refused, 63 creates / 64 refused); the ALTER TABLE ADD COLUMN path; 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 an EXIT/INT/TERM trap): 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 no dist/ 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 a483faedd5 on a clean worktree, after the final commit:

GateExitVerdict line
pnpm lint (repo-wide eslint . --no-inline-config)0clean
pnpm --filter @objectstack/driver-sql typecheck0tsc --noEmit, clean
pnpm --filter @objectstack/driver-sql full suite, live MySQL + TZ=America/New_York0Test Files 129 passed | 4 skipped (133), Tests 2317 passed | 56 skipped (2373)
check:driver-conformance0OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exemptidentical before and after
check:nul-bytes0OK (scanned 6570 text file(s) … no raw ASCII control bytes)
check:type-check-coverage0OK — 65/78 workspace packages type-checked … 13 in the DEBT ledger
check:engine-double-contract0OK — 401 pinned, 133 in the DEBT ledger, 2 exempt
check:test-source-alias0OK — 72 packages with tests scanned
check:type-source-resolution0OK — 77 packages with a tsconfig.json scanned
check: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-test0all green

Gate list derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set, not from the dispatch word.

One declared narrowing:check:type-check-debt (--re-measure) was not run locally. It re-runs tsc per ledger entry, and @objectstack/driver-sql is in neither ledger — it carries no test-typecheck-debt.json and its name appears in check-type-check-coverage.mjs only in prose. Its tsconfig.json is include: ["src/**/*"] with no test exclusion, so the new pin is inside the program pnpm --filter @objectstack/driver-sql typecheck ran 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', process TZ=America/New_York), other agents' databases left alone.

Generated by Claude Code


Generated by Claude Code

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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 12 documentable anchor(s).

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 56630b7eee0854b2569fe2579e00c43210441fd6.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 34 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 56630b7eee0854b2569fe2579e00c43210441fd6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0365471e0a260df1dc52d8473bad328b87d5199c — the merge of head a483faedd593b13195f24ff2b8f673fb7584185c into base 56630b7eee0854b2569fe2579e00c43210441fd6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 56630b7eee0854b2569fe2579e00c43210441fd6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 24, 2026 17:02
@huangyiirene
huangyiirene added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 7e83932Aug 24, 2026
32 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-11565-mysql-row-budget-preflight branch August 24, 2026 17:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-sql (MySQL): nothing sees the 65535-byte row budget, so a table with many wide declared bounds fails CREATE with an error naming no field

2 participants

@huangyiirene@claude