|
| 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