Skip to content

Commit b826390

Browse files
huangyiireneclaude
andauthored
fix(driver-sql): richtext and code take an unbounded TEXT column, restoring the declared Rich Content grouping (#11876)
* wip(driver-sql): richtext/code/signature/qrcode into the text family (#11794) INCOMPLETE — preserved so it is not lost with the container. Not for review. Done: - `richtext`, `code`, `signature`, `qrcode` added to BOTH declaration sites: the varchar-width mirror and `createColumn`. Adding to only one turns the mirror/createColumn agreement pin red by design. - `secret` (opaque sys_secret ref, ADR-0100) and `color` deliberately left in the catch-all, with reasons recorded at the site. - Corrects a pre-existing factual error: the catch-all comment listed `code` as option-valued; measured in field-zoo it stores editor contents verbatim. - 199-line test file, not yet run in a full suite. Still owed: - the #11565 agreement pin expectation update - changeset, gate union, ablation - the clause-2 judgment on whether admitting code/signature/qrcode widens the accepted physical surface beyond the declared contract. Triage's fence: if it does, stop and report. Resume this branch; do not restart from scratch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VK8rFDtg8eREaxBGX99Csn * fix(driver-sql): richtext and code take an unbounded TEXT column `createColumn`'s text-family case listed `text` / `textarea` / `html` / `markdown`. `richtext` — the third member of the spec's own "Rich Content" grouping in `field.zod.ts` — was in neither that case nor `JSON_COLUMN_TYPES`, so it fell through to the catch-all's `table.string(name)`: knex's varchar(255). Measured at 1000 characters on live MySQL 8.0.46 and Postgres 16, the write was refused by the server (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, `22001`) while the same body in a `markdown` field on the same table was accepted. `code` had the identical defect and moves with it. Membership is now decided by a stated, measured test rather than by the hand-maintained case list that let one member of a three-member spec group diverge in the first place: a type may take an unbounded TEXT column exactly when the WRITE SEAM enforces its declared `maxLength` — the invariant `schema-drift.ts` already rests on. objectql's record-validator applies its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` / `password` / `markdown` / `html` / `richtext` / `code` and to nothing else. `signature` and `qrcode` are deliberately NOT moved: nothing enforces their declared `maxLength` at the write seam, so an unbounded column would accept values the declaration forbids — a physical surface wider than the contract rather than a restoration of it. Their own defect stays open and is asserted out loud in the live-dialect suite instead of being left undocumented. Both declaration sites move together — the varchar-width mirror and `createColumn` — because their agreement is pinned by `sql-driver-11565-row-byte-budget.test.ts`. The set of types that take an unbounded column when unkeyed is now pinned as a whole, so the next addition has to be stated on purpose. 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 3954fb7 commit b826390

3 files changed

Lines changed: 347 additions & 9 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
A `richtext` field now takes an unbounded TEXT column instead of knex's `varchar(255)`, so an ordinary rich-text body over 255 characters can be written (#11794). `createColumn`'s text-family case listed `text` / `textarea` / `html` / `markdown`; `richtext` — the third member of the spec's own "Rich Content" grouping in `field.zod.ts` — was in neither that case nor `JSON_COLUMN_TYPES`, so it fell through to the catch-all's `table.string(name)`. Measured at 1000 characters on live MySQL 8.0.46 and Postgres 16: before this change the write was refused by the server (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`, `22001 value too long for type character varying(255)`) while the same body in a `markdown` field on the same table was accepted; after it, the column reads back as `text` from `information_schema` on both and the value round-trips byte-identically. `code` moves with it for the same reason.
6+
7+
Membership is now decided by a stated, measured test instead of the hand-maintained case list that let one member of a three-member spec group diverge in the first place: a type may take an unbounded TEXT column exactly when the **write seam** enforces its declared `maxLength`, which is the invariant `schema-drift.ts` already rests on ("A TEXT column refuses nothing a `maxLength` allows … the bound is enforced at the write seam"). objectql's record-validator applies its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` / `password` / `markdown` / `html` / `richtext` / `code` and to nothing else, so both moved types keep a field-named ADR-0112 refusal for an over-declared value and the physical surface is restored to the declared contract rather than widened past it. The set of types that take an unbounded column when unkeyed is pinned as a whole, so the next addition has to be stated on purpose.
8+
9+
`signature` and `qrcode` are **not** moved, deliberately and against the first reading of this defect. Their stored value is the author's own and routinely far past 255 characters (a data-URI PNG), so `varchar(255)` refuses ordinary values for them too — but the record-validator has no `max_length` branch for either, so an unbounded column would accept values a declared `maxLength` forbids: over-accepting in place of under-accepting, which is a physical surface wider than the contract. They stay bounded until the write seam can bound them, and the live-dialect suite asserts that refusal out loud rather than leaving it undocumented.
10+
11+
The #11374 keyed-and-bounded rule applies to the two new members unchanged: a keyed, bounded `richtext` / `code` column is still emitted as `varchar(maxLength)` so a declared index can key it on MySQL, and a keyed but unbounded one still gets the named `explainUnkeyableTextColumn` refusal rather than a silently weaker constraint. Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty.
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #11794 — `richtext` joins its declared "Rich Content" siblings in the TEXT
5+
* family, taking an unbounded column instead of knex's varchar(255).
6+
*
7+
* ## The defect
8+
*
9+
* `createColumn`'s text-family case listed `text` / `textarea` / `html` /
10+
* `markdown`. `richtext` — the third member of the spec's own "Rich Content"
11+
* grouping (`field.zod.ts`) — was not in it, and not in `JSON_COLUMN_TYPES`
12+
* either, so it fell through to the catch-all's `table.string(name)`:
13+
* varchar(255). That width is a hard cap on both enforcing dialects, so an
14+
* ordinary rich-text body over 255 characters was REFUSED at write time
15+
* (measured at 1000 characters on live MySQL 8.0.46 — `ER_DATA_TOO_LONG`
16+
* under `STRICT_TRANS_TABLES` — and Postgres 16 — `22001`) while the same
17+
* body in a `markdown` field on the same table was accepted.
18+
*
19+
* ## Which types moved, and the test that decided it
20+
*
21+
* `code` moved with `richtext`. `signature` and `qrcode` did NOT, and that is
22+
* the load-bearing half of this file rather than an omission.
23+
*
24+
* An unbounded TEXT column is correct for a type exactly when the WRITE SEAM
25+
* enforces that type's declared `maxLength` — the invariant `schema-drift.ts`
26+
* already states ("A TEXT column refuses nothing a `maxLength` allows … the
27+
* bound is enforced at the write seam"). objectql's record-validator applies
28+
* its `max_length` branch to `text` / `textarea` / `email` / `url` / `phone` /
29+
* `password` / `markdown` / `html` / `richtext` / `code` — and to no other
30+
* type. Measured: a `maxLength: 64` field of each of those refuses a
31+
* 100-character value; the same field declared `signature` or `qrcode`
32+
* ACCEPTS it. So for those two an unbounded column would accept values the
33+
* declaration forbids — a physical surface WIDER than the contract, where
34+
* `richtext` and `code` are a restoration of it. Their own defect (a data-URI
35+
* signature capped at 255) is real and is asserted here as an open one, so
36+
* this file records the state rather than hiding it.
37+
*
38+
* ## What each block is worth
39+
*
40+
* The SQLite blocks run everywhere (Test Core included) and read the PHYSICAL
41+
* column type back from the PRAGMA (`columnInfo()`), never the emitter. The
42+
* live cells are the enforcing half: the same table on a real MySQL /
43+
* Postgres, column types read from information_schema, a 1000-character body
44+
* accepted and round-tripped — made non-vacuous by the control write, where
45+
* the SAME oversized value into a column this change deliberately left at
46+
* varchar(255) is refused BY THE SERVER, proving the cell enforces declared
47+
* widths in this very run.
48+
*/
49+
50+
import { describe, it, expect, afterEach } from 'vitest';
51+
import { FieldType } from '@objectstack/spec/data';
52+
import { SqlDriver } from '../src/index.js';
53+
import { MYSQL_CELL, PG_CELL, dialectCell, declareDialectCell } from './live-dialect-matrix.testkit.js';
54+
55+
const T = 'os11794_text_family';
56+
57+
/** The two this card moves, their siblings, and the stay-put controls. */
58+
const FIELDS = {
59+
// Moved by #11794: varchar(255) → TEXT.
60+
body_rich: { type: 'richtext' },
61+
body_code: { type: 'code' },
62+
// Positive controls: TEXT before and after this change — the grouping was
63+
// already honoured for two of the three Rich Content members.
64+
body_md: { type: 'markdown' },
65+
body_html: { type: 'html' },
66+
// Measured and deliberately NOT moved: no write seam enforces their
67+
// `maxLength`, so TEXT would accept what the declaration forbids.
68+
body_sig: { type: 'signature' },
69+
body_qr: { type: 'qrcode' },
70+
// Negative controls: the catch-all and the string family.
71+
c_string: { type: 'string' },
72+
c_select: { type: 'select' },
73+
c_color: { type: 'color' },
74+
c_secret: { type: 'secret' },
75+
};
76+
77+
const OPTS = { bypassTenantAudit: true } as any;
78+
79+
/** A rich-text body nobody would call exotic — four times the old cap. */
80+
const LONG_BODY = `<p>${'a rich-text body well past the old varchar(255) cap — '.repeat(20)}</p>`;
81+
82+
const MOVED = ['body_rich', 'body_code'] as const;
83+
const SIBLINGS = ['body_md', 'body_html'] as const;
84+
const NOT_MOVED = ['body_sig', 'body_qr', 'c_string', 'c_select', 'c_color', 'c_secret'] as const;
85+
86+
/**
87+
* Every FieldType that takes an UNBOUNDED column when no index keys it —
88+
* pinned as a SET rather than left to the switch.
89+
*
90+
* The root cause this card names is that the case list is hand-maintained, so
91+
* one member of a three-member spec group diverged from the other two without
92+
* anything going red. A membership pin is what makes that impossible: adding a
93+
* type to `createColumn`'s text family, or to `JSON_COLUMN_TYPES`, fails here
94+
* until someone states the new membership on purpose.
95+
*/
96+
const UNBOUNDED_UNKEYED = [
97+
// text family (`createColumn`) — every member must satisfy the write-seam
98+
// invariant in this file's header.
99+
'text', 'textarea', 'html', 'markdown', 'richtext', 'code',
100+
// JSON columns and the virtual/non-varchar types: not a varchar either, for
101+
// reasons that have nothing to do with this card.
102+
'multiselect', 'checkboxes', 'tags', 'composite', 'repeater', 'record', 'json',
103+
'location', 'address', 'vector', 'image', 'file', 'avatar', 'video', 'audio',
104+
'formula', 'number', 'currency', 'percent', 'rating', 'slider', 'progress',
105+
'summary', 'boolean', 'toggle', 'date', 'datetime', 'time',
106+
].sort();
107+
108+
/** TEXT and not any varchar — `longtext`/`mediumtext` would satisfy it too. */
109+
const isTexty = (t: unknown) => /text/i.test(String(t)) && !/varchar/i.test(String(t));
110+
111+
type ColumnInfo = Record<string, { type?: string; maxLength?: number | string }>;
112+
113+
describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', () => {
114+
let driver: SqlDriver;
115+
116+
afterEach(async () => {
117+
await driver?.disconnect().catch(() => {});
118+
});
119+
120+
it('lands richtext/code as TEXT beside markdown/html — and moves nothing else', async () => {
121+
driver = new SqlDriver(dialectCell('sqlite').config());
122+
await driver.initObjects([{ name: T, fields: FIELDS }]);
123+
// The PRAGMA, not the emitter: knex's columnInfo() reads table_info.
124+
const info: ColumnInfo = await (driver as any).knex(T).columnInfo();
125+
126+
for (const moved of MOVED) {
127+
expect(isTexty(info[moved]?.type), `${moved} landed ${String(info[moved]?.type)}`).toBe(true);
128+
}
129+
for (const sibling of SIBLINGS) {
130+
expect(isTexty(info[sibling]?.type), `${sibling} landed ${String(info[sibling]?.type)}`).toBe(
131+
true,
132+
);
133+
}
134+
for (const still of NOT_MOVED) {
135+
expect(
136+
/varchar/i.test(String(info[still]?.type)),
137+
`${still} landed ${String(info[still]?.type)}`,
138+
).toBe(true);
139+
expect(Number(info[still]?.maxLength)).toBe(255);
140+
}
141+
});
142+
143+
it('round-trips a >255-character richtext body byte-identically', async () => {
144+
driver = new SqlDriver(dialectCell('sqlite').config());
145+
await driver.initObjects([{ name: T, fields: FIELDS }]);
146+
expect(LONG_BODY.length).toBeGreaterThan(255);
147+
await driver.create(T, { id: 'r1', body_rich: LONG_BODY, body_md: LONG_BODY }, OPTS);
148+
const [row] = await driver.find(T, { where: { id: 'r1' } }, OPTS);
149+
expect(row.body_rich).toBe(LONG_BODY);
150+
expect(row.body_md).toBe(LONG_BODY); // the sibling that always worked
151+
});
152+
153+
it('pins the whole unbounded-when-unkeyed SET, so the case list cannot drift again', () => {
154+
driver = new SqlDriver(dialectCell('sqlite').config());
155+
const mirror = (type: string) =>
156+
(driver as any).varcharColumnChars({ type }, undefined) as number | null;
157+
const types = FieldType.options as readonly string[];
158+
expect(types.length).toBeGreaterThan(40); // the registry really was read
159+
const unbounded = types.filter((t) => mirror(t) === null).sort();
160+
expect(unbounded.length).toBeGreaterThan(20); // and the filter really matched
161+
expect(unbounded).toEqual(UNBOUNDED_UNKEYED);
162+
// The card's minimum, spelled out: the spec's three-member "Rich Content"
163+
// group is whole again.
164+
for (const t of ['markdown', 'html', 'richtext']) expect(mirror(t)).toBeNull();
165+
// And the two that measured as wideners stay bounded.
166+
for (const t of ['signature', 'qrcode']) expect(mirror(t)).toBe(255);
167+
});
168+
169+
it('keeps #11374 keyed-and-bounded semantics for the new members', () => {
170+
driver = new SqlDriver(dialectCell('sqlite').config());
171+
const mirror = (field: any, keyed?: { unique: boolean }) =>
172+
(driver as any).varcharColumnChars(field, keyed) as number | null;
173+
// Unkeyed: TEXT, bound or not — a column no index keys on gains nothing
174+
// from a width.
175+
expect(mirror({ type: 'richtext' })).toBeNull();
176+
expect(mirror({ type: 'code', maxLength: 64 })).toBeNull();
177+
// Keyed and bounded: varchar(maxLength) — the #11374 rule, so a declared
178+
// index on a bounded code field still keys on MySQL.
179+
expect(mirror({ type: 'code', maxLength: 64 }, { unique: true })).toBe(64);
180+
// Keyed and unbounded: still TEXT — MySQL then refuses the key BY NAME
181+
// (explainUnkeyableTextColumn), never a silently weaker constraint.
182+
expect(mirror({ type: 'richtext' }, { unique: true })).toBeNull();
183+
});
184+
});
185+
186+
// ── The half only an enforcing dialect can measure ──────────────────────────
187+
188+
for (const liveCell of [PG_CELL, MYSQL_CELL]) {
189+
declareDialectCell(liveCell, 'richtext TEXT family (#11794)', (cell) => {
190+
describe(`richtext TEXT family on live ${cell.label} (#11794)`, () => {
191+
let driver: SqlDriver;
192+
193+
afterEach(async () => {
194+
await driver?.execute(`drop table if exists ${T}`).catch(() => {});
195+
await driver?.disconnect().catch(() => {});
196+
});
197+
198+
it('accepts a >255-char richtext body, and the column really is TEXT (information_schema)', async () => {
199+
driver = new SqlDriver(cell.config());
200+
await driver.execute(`drop table if exists ${T}`).catch(() => {});
201+
await driver.initObjects([{ name: T, fields: FIELDS }]);
202+
203+
// information_schema.columns, not the emitter: that is what knex's
204+
// columnInfo() reads on both of these dialects.
205+
const info: ColumnInfo = await (driver as any).knex(T).columnInfo();
206+
for (const moved of MOVED) {
207+
expect(isTexty(info[moved]?.type), `${moved} landed ${String(info[moved]?.type)}`).toBe(
208+
true,
209+
);
210+
}
211+
for (const still of NOT_MOVED) {
212+
expect(
213+
/varchar|character varying/i.test(String(info[still]?.type)),
214+
`${still} landed ${String(info[still]?.type)}`,
215+
).toBe(true);
216+
expect(Number(info[still]?.maxLength)).toBe(255);
217+
}
218+
219+
// The write this card is about: refused before this change
220+
// (ER_DATA_TOO_LONG / 22001), accepted now, byte-identical back.
221+
await driver.create(
222+
T,
223+
{ id: 'r1', body_rich: LONG_BODY, body_code: LONG_BODY, body_md: LONG_BODY },
224+
OPTS,
225+
);
226+
const [row] = await driver.find(T, { where: { id: 'r1' } }, OPTS);
227+
expect(row.body_rich).toBe(LONG_BODY);
228+
expect(row.body_code).toBe(LONG_BODY);
229+
230+
// Non-vacuity control: the SAME oversized value into a column this
231+
// change deliberately left at varchar(255) is refused BY THE SERVER.
232+
// Without this, a mis-provisioned lenient session (MySQL without
233+
// STRICT_TRANS_TABLES) would pass the acceptance above while
234+
// measuring nothing.
235+
const refusal = await driver
236+
.create(T, { id: 'r2', c_color: LONG_BODY }, OPTS)
237+
.then(() => null)
238+
.catch((e: unknown) => e);
239+
expect(refusal).toBeInstanceOf(Error);
240+
const said = `${String((refusal as { code?: string })?.code ?? '')} ${String((refusal as Error).message)}`;
241+
expect(said).toMatch(/ER_DATA_TOO_LONG|22001|too long/i);
242+
});
243+
244+
it('records the STILL-OPEN half: an oversized signature is refused by the server', async () => {
245+
// ⛔ Not a wish and not a quarantine — the current, deliberate state.
246+
// `signature` stays varchar(255) because nothing enforces its declared
247+
// `maxLength` at the write seam, so TEXT would accept what the
248+
// declaration forbids. This asserts the cost of that choice out loud:
249+
// a data-URI signature IS refused today. When the write seam gains a
250+
// bound for it, this test is what turns red and gets updated.
251+
driver = new SqlDriver(cell.config());
252+
await driver.execute(`drop table if exists ${T}`).catch(() => {});
253+
await driver.initObjects([{ name: T, fields: FIELDS }]);
254+
const dataUri = `data:image/png;base64,${'A'.repeat(1000)}`;
255+
const refusal = await driver
256+
.create(T, { id: 's1', body_sig: dataUri }, OPTS)
257+
.then(() => null)
258+
.catch((e: unknown) => e);
259+
expect(refusal).toBeInstanceOf(Error);
260+
const said = `${String((refusal as { code?: string })?.code ?? '')} ${String((refusal as Error).message)}`;
261+
expect(said).toMatch(/ER_DATA_TOO_LONG|22001|too long/i);
262+
});
263+
});
264+
});
265+
}

0 commit comments

Comments
 (0)