Skip to content

Commit 7e83932

Browse files
huangyiireneclaude
andauthored
fix(driver-sql): name the declarations behind MySQL's row-size refusal (#11792)
* fix(driver-sql): name the declarations behind MySQL's row-size refusal 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 * chore(changeset): MySQL row-size refusal names the declarations Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a29a994 commit 7e83932

3 files changed

Lines changed: 704 additions & 17 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
MySQL's row-size refusal now names the declarations that caused it (#11565). MySQL charges every bounded column's DECLARED byte width against a per-row budget, independently of the per-column `varchar` ceiling — measured on 8.0.46 through this driver, 15 fields at `maxLength: 1024` create and 16 are refused — and its own error names no column and no declaration, about a table its author described entirely in metadata. Schema sync now translates `ER_TOO_BIG_ROWSIZE` at both the `CREATE TABLE` and `ALTER TABLE ADD COLUMN` 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 (read from the server, not assumed), plus the fields that reach the budget while declaring nothing — `lookup`, `user`, `auto_number` and the option types all take `varchar(255)`. InnoDB's separate per-page limit answers with the same code and is reported with the number the server quoted rather than 65535. Deliberately a translator and not a pre-flight: it speaks only after the server has refused, so it cannot refuse an object MySQL would have accepted. Nothing is refused that was accepted before, and no other dialect is touched.
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #11565 — MySQL's per-ROW budget over DECLARED column widths, and the
5+
* diagnostic that names the declarations that spent it.
6+
*
7+
* ## The defect
8+
*
9+
* MySQL charges every bounded column's DECLARED byte width against a per-row
10+
* budget, independently of the per-column `varchar` ceiling. An object whose
11+
* fields declare enough total width simply fails `CREATE TABLE` — and the
12+
* server's refusal names **no column and no declaration**. It says "You have to
13+
* change some columns to TEXT or BLOBs" about a table its author described
14+
* entirely in metadata, and nothing maps that back to the `maxLength` values
15+
* responsible. Sixteen fields at `maxLength: 1024` is not an exotic object.
16+
*
17+
* ## Why a translator and not a pre-flight
18+
*
19+
* A pre-flight that sums declared widths BEFORE issuing DDL has to reproduce
20+
* the server's arithmetic, and wrong in the strict direction it refuses an
21+
* object MySQL would have accepted — a contract change. A translator cannot
22+
* over-refuse by construction: it speaks only after the server has refused.
23+
* Sitting inside `initObjects`' own loop it still sees `obj.fields`, so it
24+
* names every contributing field exactly as a pre-flight would.
25+
* {@link SqlDriver.explainRowSizeOverflow} carries the four measurements that
26+
* decided it; this file is the executable half.
27+
*
28+
* ## What each half is worth
29+
*
30+
* The dialect-free block runs everywhere, including Test Core. It pins the
31+
* arithmetic, the agreement between the width mirror and `createColumn`'s own
32+
* switch over every `FieldType` the spec declares, and — the half that stops
33+
* "refuses" from passing for "refuses the RIGHT objects" — that an object past
34+
* MySQL's budget still creates cleanly on a dialect that has no such budget.
35+
*
36+
* The MySQL block is the one that reds on the pre-fix tree. Opt-in:
37+
*
38+
* OS_TEST_MYSQL_URL=mysql://root:root@127.0.0.1:3306/conformance \
39+
* pnpm --filter @objectstack/driver-sql test
40+
*/
41+
42+
import { describe, it, expect, afterEach } from 'vitest';
43+
import { FieldType } from '@objectstack/spec/data';
44+
import { SqlDriver } from '../src/index.js';
45+
import { MYSQL_CELL, dialectCell, declareDialectCell } from './live-dialect-matrix.testkit.js';
46+
47+
/** An object of `count` string fields, each declaring the same `maxLength`. */
48+
const wideObject = (name: string, count: number, maxLength: number) => ({
49+
name,
50+
fields: Object.fromEntries(
51+
Array.from({ length: count }, (_, i) => [`f${i + 1}`, { type: 'string', maxLength }]),
52+
),
53+
});
54+
55+
/** The same shape with NOTHING declared — `lookup` takes knex's varchar(255). */
56+
const undeclaredObject = (name: string, count: number) => ({
57+
name,
58+
fields: Object.fromEntries(
59+
Array.from({ length: count }, (_, i) => [`f${i + 1}`, { type: 'lookup' }]),
60+
),
61+
});
62+
63+
// ── The arithmetic, and the mirror, on a dialect every runner has ───────────
64+
65+
describe('row byte budget — arithmetic and column mirror (#11565)', () => {
66+
let driver: SqlDriver;
67+
68+
afterEach(async () => {
69+
await driver?.disconnect().catch(() => {});
70+
});
71+
72+
/**
73+
* The length prefix moves at a BYTE-payload boundary, not a character one, so
74+
* it moves with the charset — measured on MySQL 8.0.46 through the column
75+
* counts a table can hold. A utf8mb4 `varchar(63)` payload is 252 bytes and
76+
* 32 of them fit InnoDB's 8126-byte page limit (32 x 253 = 8096; at 254 bytes
77+
* each, 32 would not fit), so that prefix is one byte. A `varchar(64)`
78+
* payload is 256 bytes and behaves as the two-byte, off-page-eligible class.
79+
*
80+
* ⚠️ A payload of EXACTLY 255 is documented as the last one-byte width, and
81+
* is deliberately not asserted here: such a column is never stored off-page,
82+
* so the page limit binds at ~31 columns and neither limit can be made to
83+
* discriminate 256 from 257 bytes. An unmeasurable claim does not get a pin.
84+
*/
85+
it('charges payload + varchar length prefix, and the prefix moves with the charset', () => {
86+
const pack = (chars: number, bpc: number) => (SqlDriver as any).varcharPackLength(chars, bpc);
87+
expect(pack(63, 4)).toBe(253); // utf8mb4: 252 + 1
88+
expect(pack(64, 4)).toBe(258); // utf8mb4: 256 + 2
89+
expect(pack(63, 1)).toBe(64); // latin1: 63 + 1 — the same width, a quarter the cost
90+
expect(pack(300, 1)).toBe(302); // latin1: 300 + 2
91+
expect(pack(1024, 4)).toBe(4098); // the card's own row: 16 x 4098 = 65568 > 65535
92+
expect(pack(255, 4)).toBe(1022); // the DEFAULT width, which declares nothing
93+
});
94+
95+
/**
96+
* ⚠️ The mirror is a second reading of `createColumn`'s switch, so the risk it
97+
* carries is drift. This pin removes the risk structurally rather than by
98+
* review: one field of EVERY `FieldType` the spec declares, created for real,
99+
* and the mirror's answer compared against the column that actually landed. A
100+
* type added to the spec joins this pin without anyone remembering to.
101+
*/
102+
it('agrees with createColumn about every FieldType the spec declares', async () => {
103+
const types = FieldType.options as readonly string[];
104+
expect(types.length).toBeGreaterThan(40); // the registry really was read
105+
106+
const fields = Object.fromEntries(types.map((t) => [`f_${t}`, { type: t }]));
107+
driver = new SqlDriver(dialectCell('sqlite').config());
108+
await driver.initObjects([{ name: 'os11565_every_type', fields }]);
109+
const info: Record<string, { type?: string; maxLength?: number | string }> = await (
110+
driver as any
111+
).knex('os11565_every_type').columnInfo();
112+
113+
const mismatched: string[] = [];
114+
for (const t of types) {
115+
const column = `f_${t}`;
116+
const mirrored = (driver as any).varcharColumnChars({ type: t }, undefined) as number | null;
117+
const landed = info[column];
118+
const isVarchar = /varchar/i.test(String(landed?.type ?? ''));
119+
const landedChars = isVarchar ? Number(landed?.maxLength) : null;
120+
if (mirrored !== landedChars) {
121+
mismatched.push(
122+
`${t}: mirror says ${mirrored === null ? 'not a varchar' : `varchar(${mirrored})`}, ` +
123+
`createColumn emitted ${landed === undefined ? 'no column' : String(landed.type)}`,
124+
);
125+
}
126+
}
127+
expect(mismatched).toEqual([]);
128+
});
129+
130+
/**
131+
* ⛔ The negative half, and the reason "it refuses" is not the assertion this
132+
* file makes: the budget is MySQL's, so an object past it must still create
133+
* cleanly everywhere else. A pre-flight with a wrong constant would fail
134+
* exactly here; a translator cannot, because it never runs.
135+
*/
136+
it('refuses nothing on a dialect with no row budget', async () => {
137+
driver = new SqlDriver(dialectCell('sqlite').config());
138+
// 16 x maxLength 1024 — the shape live MySQL rejects, three lines down in
139+
// the same repo.
140+
await driver.initObjects([wideObject('os11565_wide_sqlite', 16, 1024)]);
141+
const info: any = await (driver as any).knex('os11565_wide_sqlite').columnInfo();
142+
expect(Object.keys(info)).toContain('f16');
143+
expect(String(info.f16?.maxLength ?? '')).toBe('1024');
144+
});
145+
146+
/** The offender list is ordered by cost, and holds every varchar column. */
147+
it('profiles every varchar column, widest first', async () => {
148+
driver = new SqlDriver(dialectCell('sqlite').config());
149+
const profile = (driver as any).rowWidthProfile(
150+
{
151+
narrow: { type: 'string', maxLength: 10 },
152+
widest: { type: 'string', maxLength: 4000 },
153+
middle: { type: 'string', maxLength: 1024 },
154+
unbounded_lookup: { type: 'lookup' },
155+
a_number: { type: 'number' },
156+
// Unkeyed text stays TEXT — it costs the budget a pointer, not a width.
157+
body: { type: 'text', maxLength: 60000 },
158+
id: { type: 'string', maxLength: 9999 }, // built-in, never the author's
159+
},
160+
new Map(),
161+
4,
162+
);
163+
expect(profile.columns.map((c: any) => c.name)).toEqual([
164+
'widest',
165+
'middle',
166+
'unbounded_lookup',
167+
'narrow',
168+
]);
169+
expect(profile.columns[0]).toMatchObject({ chars: 4000, bytes: 16002 });
170+
expect(profile.columns[2]).toMatchObject({ chars: 255, bytes: 1022 });
171+
expect(profile.totalBytes).toBe(16002 + 4098 + 1022 + 41);
172+
});
173+
});
174+
175+
// ── The half only a live MySQL can measure ──────────────────────────────────
176+
177+
declareDialectCell(MYSQL_CELL, 'row byte budget (#11565)', (cell) => {
178+
describe('row byte budget on live MySQL (#11565)', () => {
179+
let driver: SqlDriver;
180+
const TABLES = [
181+
'os11565_ok',
182+
'os11565_over',
183+
'os11565_ok255',
184+
'os11565_over255',
185+
'os11565_grow',
186+
'os11565_narrow',
187+
'os11565_undeclared',
188+
];
189+
190+
afterEach(async () => {
191+
for (const t of TABLES) await driver?.execute(`drop table if exists ${t}`).catch(() => {});
192+
await driver?.disconnect().catch(() => {});
193+
});
194+
195+
/**
196+
* The measured boundaries below are utf8mb4's. On a latin1 schema the same
197+
* declarations cost a quarter as much and every count here is wrong — so
198+
* the cell asserts the multiplier rather than assuming it, the same way the
199+
* matrix asserts its zone skew instead of hoping for it.
200+
*/
201+
it('runs on a 4-byte charset — the boundaries below are utf8mb4 numbers', async () => {
202+
driver = new SqlDriver(cell.config());
203+
const seen = await (driver as any).schemaBytesPerChar();
204+
expect(seen).not.toBeNull();
205+
expect(seen.bytesPerChar).toBe(4);
206+
});
207+
208+
/**
209+
* Both sides of the boundary, in one test, because only the pair means
210+
* anything: an implementation that refused every object would pass the
211+
* second assertion alone. Measured through this driver (its built-in `id`
212+
* varchar(255) is inside the budget, which is why the count is 15/16 here
213+
* and 15/16 in raw SQL only by coincidence of the same width).
214+
*/
215+
it('creates 15 fields at maxLength 1024 and names all 16 when one more is added', async () => {
216+
driver = new SqlDriver(cell.config());
217+
await driver.execute('drop table if exists os11565_ok');
218+
await driver.execute('drop table if exists os11565_over');
219+
220+
// ACCEPTS: unchanged behaviour, no diagnostic, a real table.
221+
await driver.initObjects([wideObject('os11565_ok', 15, 1024)]);
222+
const info: any = await (driver as any).knex('os11565_ok').columnInfo();
223+
expect(String(info.f15?.type)).toBe('varchar');
224+
expect(Number(info.f15?.maxLength)).toBe(1024);
225+
226+
// REFUSES — with the fields the server would not name.
227+
const failure = await driver
228+
.initObjects([wideObject('os11565_over', 16, 1024)])
229+
.then(() => null)
230+
.catch((e: any) => e);
231+
expect(failure).toBeInstanceOf(Error);
232+
const message = String(failure.message);
233+
expect(message).toMatch(/cannot create table "os11565_over"/);
234+
expect(message).toMatch(/65535-byte budget for one ROW/);
235+
// Every contributing field, not merely "the table failed".
236+
expect(message).toMatch(/Its 16 varchar column\(s\) take 65568 bytes/);
237+
expect(message).toMatch(/"f1" varchar\(1024\) = 4098 bytes/);
238+
expect(message).toMatch(/and 8 more/);
239+
// The server's own sentence is kept, not replaced.
240+
expect(message).toMatch(/server said: Row size too large/);
241+
// Same failure, re-worded: the code survives for anything reading it.
242+
expect(failure.code).toBe('ER_TOO_BIG_ROWSIZE');
243+
expect((failure.cause as any)?.code).toBe('ER_TOO_BIG_ROWSIZE');
244+
245+
// ⛔ And nothing was left behind: the object is not registered half-built.
246+
const exists = await (driver as any).knex.schema.hasTable('os11565_over');
247+
expect(exists).toBe(false);
248+
});
249+
250+
/** The card's second measured row, moved by the driver's own `id` column. */
251+
it('creates 63 fields at maxLength 255 and refuses 64', async () => {
252+
driver = new SqlDriver(cell.config());
253+
await driver.execute('drop table if exists os11565_ok255');
254+
await driver.execute('drop table if exists os11565_over255');
255+
256+
await driver.initObjects([wideObject('os11565_ok255', 63, 255)]);
257+
expect(await (driver as any).knex.schema.hasTable('os11565_ok255')).toBe(true);
258+
259+
await expect(driver.initObjects([wideObject('os11565_over255', 64, 255)])).rejects.toThrow(
260+
/cannot create table "os11565_over255".*Its 64 varchar column\(s\) take 65408 bytes/s,
261+
);
262+
});
263+
264+
/**
265+
* The path that is more likely than CREATE in a living app: a field added
266+
* to an object that was already near the budget. The server refuses the ADD
267+
* naming only the column being added, as if that one column were too wide —
268+
* when the width is in fifteen columns nobody is touching.
269+
*/
270+
it('names the whole row when ALTER TABLE ADD COLUMN crosses the budget', async () => {
271+
driver = new SqlDriver(cell.config());
272+
await driver.execute('drop table if exists os11565_grow');
273+
await driver.initObjects([wideObject('os11565_grow', 15, 1024)]);
274+
275+
await expect(driver.initObjects([wideObject('os11565_grow', 16, 1024)])).rejects.toThrow(
276+
/cannot add column\(s\) "f16" to "os11565_grow".*Its 16 varchar column\(s\)/s,
277+
);
278+
});
279+
280+
/**
281+
* The SECOND limit, which the card's threshold table does not reach and a
282+
* 65535-byte pre-flight would have waved through: InnoDB's per-page limit,
283+
* hit here by forty ordinary `maxLength: 63` fields — about a sixth of the
284+
* 65535 budget. Reported with the number the SERVER quoted, not with 65535.
285+
*/
286+
it('reports InnoDB page-limit refusals with the page limit, not the row budget', async () => {
287+
driver = new SqlDriver(cell.config());
288+
await driver.execute('drop table if exists os11565_narrow');
289+
290+
const failure = await driver
291+
.initObjects([wideObject('os11565_narrow', 40, 63)])
292+
.then(() => null)
293+
.catch((e: any) => e);
294+
expect(failure).toBeInstanceOf(Error);
295+
const message = String(failure.message);
296+
expect(message).toMatch(/InnoDB's per-PAGE limit of 8126 bytes/);
297+
expect(message).not.toMatch(/65535-byte budget for one ROW/);
298+
expect(message).toMatch(/"f1" varchar\(63\) = 253 bytes/);
299+
});
300+
301+
/**
302+
* The shape a diagnostic reading only DECLARED bounds would have nothing to
303+
* say about: sixty-four `lookup` fields, no `maxLength` anywhere, each
304+
* silently taking knex's varchar(255).
305+
*/
306+
it('names the fields even when nothing declares a maxLength', async () => {
307+
driver = new SqlDriver(cell.config());
308+
await driver.execute('drop table if exists os11565_undeclared');
309+
310+
const failure = await driver
311+
.initObjects([undeclaredObject('os11565_undeclared', 64)])
312+
.then(() => null)
313+
.catch((e: any) => e);
314+
expect(failure).toBeInstanceOf(Error);
315+
const message = String(failure.message);
316+
expect(message).toMatch(/"f1" varchar\(255\) = 1022 bytes/);
317+
expect(message).toMatch(/a field declaring NO `maxLength` still takes varchar\(255\)/i);
318+
});
319+
});
320+
});

0 commit comments

Comments
 (0)