From 5311cee259d8d7c7f615958cbd58b1fa1fb16c8f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 09:15:19 +0000 Subject: [PATCH] feat(data): signature and qrcode join the bounded-string family end to end (#11875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-08-25, option 1. Three seams move together: - spec: both types join BOUNDED_STRING_FIELD_TYPES, so maxLength/minLength become authorable on them; the out-of-set refusal message now enumerates the set itself instead of a hand-written copy (the #12017 two-copies shape), and both authoring forms show the key for the same set. - objectql: the record-validator's max_length/min_length branch reads the spec's set instead of a hand-copied ten-type list — a declared bound on signature/qrcode refuses over-long values with a field-named ADR-0112 envelope, boundary included (64 accepted, 65 refused at maxLength: 64). - driver-sql: both types move from the catch-all's varchar(255) into the TEXT family under exactly the #11794 invariant — TEXT is permitted because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (STRICT_TRANS_TABLES) and Postgres 16: the 1000-char data-URI that was refused ER_DATA_TOO_LONG / 22001 lands as text per information_schema.COLUMNS and round-trips byte-identically; a keyed, bounded column is varchar(maxLength) with the server refusing exactly one char past the bound (#11374 semantics unchanged). secret and color are explicitly NOT covered (opaque sys_secret ref per ADR-0100; short by construction). Existing tables are untouched — the additive sync never rewrites a column's type. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- .../signature-qrcode-bounded-text-family.md | 13 ++ content/docs/references/data/field.mdx | 2 +- ...-driver-11794-richtext-text-family.test.ts | 153 ++++++++++++++---- packages/drivers/driver-sql/src/sql-driver.ts | 59 ++++--- .../src/validation/record-validator.test.ts | 97 +++++++++++ .../src/validation/record-validator.ts | 18 ++- packages/spec/src/data/field.form.ts | 10 +- packages/spec/src/data/field.test.ts | 24 +-- packages/spec/src/data/field.zod.ts | 33 +++- packages/spec/src/data/object.form.ts | 12 +- 10 files changed, 329 insertions(+), 92 deletions(-) create mode 100644 .changeset/signature-qrcode-bounded-text-family.md diff --git a/.changeset/signature-qrcode-bounded-text-family.md b/.changeset/signature-qrcode-bounded-text-family.md new file mode 100644 index 0000000000..42ae919de5 --- /dev/null +++ b/.changeset/signature-qrcode-bounded-text-family.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/driver-sql": minor +--- + +`signature` and `qrcode` join the bounded-string family end to end, closing the last measured hole #11794 left open (#11875, maintainer ruling 2026-08-25, option 1). Three seams move together, in the order that keeps declared = enforced at every step: + +- **Authoring (`@objectstack/spec`)**: `maxLength` / `minLength` become authorable on `signature` and `qrcode` — both types join `BOUNDED_STRING_FIELD_TYPES`, so `Field.signature({ maxLength: 64 })`, refused at the authoring seam since #11566, now parses. The refusal message for the remaining out-of-set types enumerates the set itself instead of a hand-written copy of it, and both authoring forms show the key for the same set. +- **Write seam (`@objectstack/objectql`)**: the record-validator's `max_length` / `min_length` branch now reads the spec's `BOUNDED_STRING_FIELD_TYPES` instead of a hand-copied ten-type list, so a declared bound on `signature` / `qrcode` refuses an over-long value with a field-named ADR-0112 `max_length` envelope — boundary measured: exactly `maxLength` characters is accepted, one past it is refused, on insert and update. `secret` and `color` are deliberately NOT covered (opaque `sys_secret` ref per ADR-0100; short by construction — the ruling's explicit carve-outs). +- **Storage (`@objectstack/driver-sql`)**: both types move from the catch-all's `varchar(255)` into the TEXT family, under exactly the invariant #11794 established — an unbounded TEXT column is permitted precisely because the write seam now enforces the declared bound. Measured on live MySQL 8.0.46 (`STRICT_TRANS_TABLES`) and Postgres 16: a 1000-character data-URI signature, previously refused by the server (`ER_DATA_TOO_LONG` / `22001`), lands in a column that reads back as `text` from `information_schema.COLUMNS` on both dialects and round-trips byte-identically. The #11374 keyed-and-bounded rule applies to them unchanged: a keyed, bounded column is emitted `varchar(maxLength)` and the server refuses exactly one character past the declared bound. + +Nothing about existing tables changes — `createColumn` runs on `CREATE TABLE` and `ALTER TABLE ADD COLUMN`, so the column it sizes is always empty; a pre-existing `signature` / `qrcode` column stays `varchar(255)` until an operator migrates it, and the additive sync never rewrites a column's type on its own. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 634977cbfc..b70d67b397 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -65,7 +65,7 @@ const result = CurrencyConfigSchema.parse(data); | **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (#9447, maintainer ruling 2026-08-18). | | **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes (#7127), discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | -| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code. | +| **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `number` | optional | Min character length | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | diff --git a/packages/drivers/driver-sql/src/sql-driver-11794-richtext-text-family.test.ts b/packages/drivers/driver-sql/src/sql-driver-11794-richtext-text-family.test.ts index b3f609f23f..da2ac357b1 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11794-richtext-text-family.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11794-richtext-text-family.test.ts @@ -18,22 +18,32 @@ * * ## Which types moved, and the test that decided it * - * `code` moved with `richtext`. `signature` and `qrcode` did NOT, and that is - * the load-bearing half of this file rather than an omission. + * `code` moved with `richtext`. `signature` and `qrcode` did NOT move in + * #11794, and this file asserted that half out loud as an OPEN defect. * * An unbounded TEXT column is correct for a type exactly when the WRITE SEAM * enforces that type's declared `maxLength` — the invariant `schema-drift.ts` * already states ("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 no other - * type. Measured: a `maxLength: 64` field of each of those refuses a - * 100-character value; the same field declared `signature` or `qrcode` - * ACCEPTS it. So for those two an unbounded column would accept values the - * declaration forbids — a physical surface WIDER than the contract, where - * `richtext` and `code` are a restoration of it. Their own defect (a data-URI - * signature capped at 255) is real and is asserted here as an open one, so - * this file records the state rather than hiding it. + * bound is enforced at the write seam"). At #11794 objectql's record-validator + * applied its `max_length` branch to `text` / `textarea` / `email` / `url` / + * `phone` / `password` / `markdown` / `html` / `richtext` / `code` — and to no + * other type — so for `signature` / `qrcode` an unbounded column would have + * accepted values the declaration forbids: a physical surface WIDER than the + * contract, where `richtext` and `code` were a restoration of it. + * + * ## #11875 closed that half (maintainer ruling 2026-08-25, option 1) + * + * `signature` and `qrcode` joined the spec's BOUNDED_STRING_FIELD_TYPES, the + * authoring seam admits `maxLength` on them, and the record-validator's + * `max_length` branch reads that same set — so the write seam now enforces + * their declared bound and the invariant above licenses their TEXT column. + * The former "STILL-OPEN half" cases below are the same measurements in their + * CLOSED shape: the data-URI that was refused `22001` / `ER_DATA_TOO_LONG` at + * varchar(255) is accepted and round-trips byte-identically, and the #11374 + * keyed-and-bounded rule applies to them the way it applies to every other + * text-family member (keyed + bounded ⇒ varchar(maxLength), physically + * enforced at exactly the declared bound; otherwise TEXT, bound enforced at + * the write seam). * * ## What each block is worth * @@ -54,7 +64,7 @@ import { MYSQL_CELL, PG_CELL, dialectCell, declareDialectCell } from './live-dia const T = 'os11794_text_family'; -/** The two this card moves, their siblings, and the stay-put controls. */ +/** The types the two cards move, their siblings, and the stay-put controls. */ const FIELDS = { // Moved by #11794: varchar(255) → TEXT. body_rich: { type: 'richtext' }, @@ -63,11 +73,13 @@ const FIELDS = { // already honoured for two of the three Rich Content members. body_md: { type: 'markdown' }, body_html: { type: 'html' }, - // Measured and deliberately NOT moved: no write seam enforces their - // `maxLength`, so TEXT would accept what the declaration forbids. + // Moved by #11875, once the write seam gained their declared bound + // (BOUNDED_STRING_FIELD_TYPES): varchar(255) → TEXT. body_sig: { type: 'signature' }, body_qr: { type: 'qrcode' }, - // Negative controls: the catch-all and the string family. + // Negative controls: the catch-all and the string family. `color` and + // `secret` are the #11875 ruling's explicit carve-outs (short by + // construction; opaque `sys_secret` ref per ADR-0100). c_string: { type: 'string' }, c_select: { type: 'select' }, c_color: { type: 'color' }, @@ -79,9 +91,13 @@ const OPTS = { bypassTenantAudit: true } as any; /** A rich-text body nobody would call exotic — four times the old cap. */ const LONG_BODY = `

${'a rich-text body well past the old varchar(255) cap — '.repeat(20)}

`; +/** The value the #11875 half is about: an ordinary data-URI signature. */ +const DATA_URI = `data:image/png;base64,${'A'.repeat(1000)}`; + const MOVED = ['body_rich', 'body_code'] as const; const SIBLINGS = ['body_md', 'body_html'] as const; -const NOT_MOVED = ['body_sig', 'body_qr', 'c_string', 'c_select', 'c_color', 'c_secret'] as const; +const MOVED_11875 = ['body_sig', 'body_qr'] as const; +const NOT_MOVED = ['c_string', 'c_select', 'c_color', 'c_secret'] as const; /** * Every FieldType that takes an UNBOUNDED column when no index keys it — @@ -95,8 +111,10 @@ const NOT_MOVED = ['body_sig', 'body_qr', 'c_string', 'c_select', 'c_color', 'c_ */ const UNBOUNDED_UNKEYED = [ // text family (`createColumn`) — every member must satisfy the write-seam - // invariant in this file's header. + // invariant in this file's header. `signature`/`qrcode` joined at #11875, + // when the write seam gained their declared bound. 'text', 'textarea', 'html', 'markdown', 'richtext', 'code', + 'signature', 'qrcode', // JSON columns and the virtual/non-varchar types: not a varchar either, for // reasons that have nothing to do with this card. 'multiselect', 'checkboxes', 'tags', 'composite', 'repeater', 'record', 'json', @@ -117,13 +135,13 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', await driver?.disconnect().catch(() => {}); }); - it('lands richtext/code as TEXT beside markdown/html — and moves nothing else', async () => { + it('lands richtext/code and signature/qrcode as TEXT beside markdown/html — and moves nothing else', async () => { driver = new SqlDriver(dialectCell('sqlite').config()); await driver.initObjects([{ name: T, fields: FIELDS }]); // The PRAGMA, not the emitter: knex's columnInfo() reads table_info. const info: ColumnInfo = await (driver as any).knex(T).columnInfo(); - for (const moved of MOVED) { + for (const moved of [...MOVED, ...MOVED_11875]) { expect(isTexty(info[moved]?.type), `${moved} landed ${String(info[moved]?.type)}`).toBe(true); } for (const sibling of SIBLINGS) { @@ -150,6 +168,16 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', expect(row.body_md).toBe(LONG_BODY); // the sibling that always worked }); + it('round-trips a >255-character data-URI signature/qrcode byte-identically (#11875)', async () => { + driver = new SqlDriver(dialectCell('sqlite').config()); + await driver.initObjects([{ name: T, fields: FIELDS }]); + expect(DATA_URI.length).toBeGreaterThan(255); + await driver.create(T, { id: 's1', body_sig: DATA_URI, body_qr: DATA_URI }, OPTS); + const [row] = await driver.find(T, { where: { id: 's1' } }, OPTS); + expect(row.body_sig).toBe(DATA_URI); + expect(row.body_qr).toBe(DATA_URI); + }); + it('pins the whole unbounded-when-unkeyed SET, so the case list cannot drift again', () => { driver = new SqlDriver(dialectCell('sqlite').config()); const mirror = (type: string) => @@ -162,8 +190,11 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', // The card's minimum, spelled out: the spec's three-member "Rich Content" // group is whole again. for (const t of ['markdown', 'html', 'richtext']) expect(mirror(t)).toBeNull(); - // And the two that measured as wideners stay bounded. - for (const t of ['signature', 'qrcode']) expect(mirror(t)).toBe(255); + // #11875: the two former wideners moved once the write seam gained their + // bound — TEXT when unkeyed, like every other text-family member. + for (const t of ['signature', 'qrcode']) expect(mirror(t)).toBeNull(); + // The ruling's explicit carve-outs stay bounded in the catch-all. + for (const t of ['color', 'secret']) expect(mirror(t)).toBe(255); }); it('keeps #11374 keyed-and-bounded semantics for the new members', () => { @@ -180,6 +211,16 @@ describe('richtext joins the TEXT family (#11794) — physical shape on SQLite', // Keyed and unbounded: still TEXT — MySQL then refuses the key BY NAME // (explainUnkeyableTextColumn), never a silently weaker constraint. expect(mirror({ type: 'richtext' }, { unique: true })).toBeNull(); + // #11875: the same four corners for the two new members. The declared + // bound's ENFORCEMENT never depends on the index — the write seam holds it + // in every corner — the index only decides whether the COLUMN also + // enforces it (varchar(n) keyed, TEXT otherwise), exactly as for `code`. + for (const type of ['signature', 'qrcode']) { + expect(mirror({ type })).toBeNull(); + expect(mirror({ type, maxLength: 64 })).toBeNull(); + expect(mirror({ type, maxLength: 64 }, { unique: true })).toBe(64); + expect(mirror({ type }, { unique: true })).toBeNull(); + } }); }); @@ -203,7 +244,7 @@ for (const liveCell of [PG_CELL, MYSQL_CELL]) { // information_schema.columns, not the emitter: that is what knex's // columnInfo() reads on both of these dialects. const info: ColumnInfo = await (driver as any).knex(T).columnInfo(); - for (const moved of MOVED) { + for (const moved of [...MOVED, ...MOVED_11875]) { expect(isTexty(info[moved]?.type), `${moved} landed ${String(info[moved]?.type)}`).toBe( true, ); @@ -241,24 +282,72 @@ for (const liveCell of [PG_CELL, MYSQL_CELL]) { expect(said).toMatch(/ER_DATA_TOO_LONG|22001|too long/i); }); - it('records the STILL-OPEN half: an oversized signature is refused by the server', async () => { - // ⛔ Not a wish and not a quarantine — the current, deliberate state. - // `signature` stays varchar(255) because nothing enforces its declared - // `maxLength` at the write seam, so TEXT would accept what the - // declaration forbids. This asserts the cost of that choice out loud: - // a data-URI signature IS refused today. When the write seam gains a - // bound for it, this test is what turns red and gets updated. + it('closes the formerly-open half (#11875): an oversized data-URI signature/qrcode is accepted and round-trips', async () => { + // The #11794 version of this case asserted the COST of leaving + // `signature`/`qrcode` at varchar(255) out loud: the data-URI below + // was refused BY THE SERVER (`ER_DATA_TOO_LONG` / `22001`). The write + // seam has since gained their declared bound (#11875, + // BOUNDED_STRING_FIELD_TYPES), the column is TEXT, and the same value + // is accepted — this is that red turned green, not a deleted control. + // Non-vacuity for this cell is carried by the c_color refusal in the + // test above: the same session refuses an oversized write into a + // column that stayed varchar(255). driver = new SqlDriver(cell.config()); await driver.execute(`drop table if exists ${T}`).catch(() => {}); await driver.initObjects([{ name: T, fields: FIELDS }]); - const dataUri = `data:image/png;base64,${'A'.repeat(1000)}`; + await driver.create(T, { id: 's1', body_sig: DATA_URI, body_qr: DATA_URI }, OPTS); + const [row] = await driver.find(T, { where: { id: 's1' } }, OPTS); + expect(row.body_sig).toBe(DATA_URI); + expect(row.body_qr).toBe(DATA_URI); + }); + + it('keeps #11374 keyed-and-bounded semantics live for the new members (#11875)', async () => { + // A KEYED bounded signature/qrcode column is varchar(maxLength) — the + // physical catalog says so — and the server enforces EXACTLY the + // declared bound: the boundary value fits, one past it is refused. + // The declared bound's enforcement therefore never diverges by shape: + // unkeyed columns are TEXT with the same bound enforced at the write + // seam (record-validator, pinned in objectql), keyed columns enforce + // it physically too. Both directions measured, boundary included. + const KT = `${T}_keyed`; + // Hoisted (not an inline literal) the way #11374's `boundedObject()` + // is: `indexes` rides through `initObjects` beyond its narrow + // parameter type, exactly as the platform objects declare it. + const keyedObject = { + name: KT, + fields: { + sig: { type: 'signature', maxLength: 64 }, + qr: { type: 'qrcode', maxLength: 64 }, + }, + indexes: [ + { fields: ['sig'], unique: true }, + { fields: ['qr'], unique: false }, + ], + }; + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${KT}`).catch(() => {}); + await driver.initObjects([keyedObject]); + const info: ColumnInfo = await (driver as any).knex(KT).columnInfo(); + for (const col of ['sig', 'qr']) { + expect( + /varchar|character varying/i.test(String(info[col]?.type)), + `${col} landed ${String(info[col]?.type)}`, + ).toBe(true); + expect(Number(info[col]?.maxLength)).toBe(64); + } + // Boundary value: exactly maxLength characters is ACCEPTED. + await driver.create(KT, { id: 'b1', sig: 'x'.repeat(64), qr: 'y'.repeat(64) }, OPTS); + const [row] = await driver.find(KT, { where: { id: 'b1' } }, OPTS); + expect(row.sig).toBe('x'.repeat(64)); + // One past the boundary: refused by the SERVER at the declared bound. const refusal = await driver - .create(T, { id: 's1', body_sig: dataUri }, OPTS) + .create(KT, { id: 'b2', qr: 'y'.repeat(65) }, OPTS) .then(() => null) .catch((e: unknown) => e); expect(refusal).toBeInstanceOf(Error); const said = `${String((refusal as { code?: string })?.code ?? '')} ${String((refusal as Error).message)}`; expect(said).toMatch(/ER_DATA_TOO_LONG|22001|too long/i); + await driver.execute(`drop table if exists ${KT}`).catch(() => {}); }); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 6c12539f68..19e2241350 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -13483,6 +13483,10 @@ export class SqlDriver implements IDataDriver { case 'markdown': case 'richtext': case 'code': + // #11875 — moved out of the catch-all together with `createColumn`'s + // text-family case: the write seam now enforces their declared bound. + case 'signature': + case 'qrcode': return keyed ? this.keyableTextLength(field) : null; // Virtual — `createColumn` returns without emitting anything. case 'formula': @@ -13792,13 +13796,11 @@ export class SqlDriver implements IDataDriver { // declaration still binds once the column stops binding. // // objectql's record-validator applies its `max_length` / `min_length` - // branch to exactly `text` / `textarea` / `email` / `url` / `phone` / - // `password` / `markdown` / `html` / `richtext` / `code`. Both new - // members are inside that list — measured, not read off it: a - // `maxLength: 64` field of each type refuses a 100-character value with - // a field-named ADR-0112 envelope, before any column is reached. So - // moving them here RESTORES the declared contract (any string, as - // `valueSchemaFor` says) instead of widening past it. + // branch to exactly the spec's BOUNDED_STRING_FIELD_TYPES — measured, + // not read off it: a `maxLength: 64` field of each member refuses a + // 100-character value with a field-named ADR-0112 envelope, before any + // column is reached. So membership here RESTORES the declared contract + // (any string, as `valueSchemaFor` says) instead of widening past it. // // `richtext` is the headline member: the spec groups `markdown` / `html` // / `richtext` together as "Rich Content" (`field.zod.ts`) and two of @@ -13810,17 +13812,21 @@ export class SqlDriver implements IDataDriver { // (`ER_DATA_TOO_LONG` under `STRICT_TRANS_TABLES`) and Postgres 16 // (`22001`). `code` is the same defect on the same evidence — a code // editor's contents, refused identically on both dialects. - // - // ⛔ `signature` and `qrcode` are STRING_VALUE_TYPES members whose stored - // value is also the author's own and also routinely far past 255 - // characters (field-zoo writes a data-URI PNG for `signature`), and they - // are deliberately NOT here — see the catch-all's note. The validator - // branch above does not list them, so nothing enforces their declared - // `maxLength` anywhere: for them an unbounded TEXT column would accept - // values the declaration forbids, which is a widening of the physical - // surface past the contract rather than a restoration of it. case 'richtext': - case 'code': { + case 'code': + // #11875 (maintainer ruling 2026-08-25, option 1): `signature` and + // `qrcode` join under exactly the invariant above. #11794 measured them + // (same live refusal: a 1000-char data-URI landed varchar(255) and was + // refused `22001` on PG, `ER_DATA_TOO_LONG` on MySQL) and deliberately + // left them in the catch-all, because at that point NOTHING enforced + // their declared `maxLength` — an unbounded TEXT would have accepted + // values the declaration forbids. That gap is now closed at both seams: + // `FieldSchema` admits `maxLength` on them (BOUNDED_STRING_FIELD_TYPES) + // and the record-validator's `max_length` branch reads the same set, so + // a TEXT column here refuses nothing the declaration allows — the bound + // is enforced at the write seam, and the data-URI refusal goes away. + case 'signature': + case 'qrcode': { // #11374: a text-family column that some declared index KEYS ON is // emitted as `varchar(maxLength)` rather than TEXT, whenever the field // declared a bound this dialect can key on. @@ -13977,20 +13983,11 @@ export class SqlDriver implements IDataDriver { // any of those from the author's `maxLength` would size the wrong // string. (`code` used to be mis-listed here among the option-valued // ones — measured in field-zoo it stores the editor's contents - // verbatim, which is why #11794 moved it to the text family above.) - // - // ⚠️ `signature` and `qrcode` are here for a DIFFERENT reason, and it is - // an OPEN DEFECT rather than a design. Their stored value IS the - // declared value and it is routinely far past 255 characters — a - // data-URI PNG for `signature` — so varchar(255) refuses ordinary - // authored values on both enforcing dialects, exactly the way it did - // for `richtext`. #11794 measured them and left them here anyway, - // because NOTHING enforces their declared `maxLength`: the - // record-validator's `max_length` branch does not list them, so an - // unbounded TEXT column would trade an under-accepting column for an - // over-accepting one — a physical surface wider than the contract. - // They need an enforced bound before they can move; see the text-family - // case above for the invariant that decides it. + // verbatim, which is why #11794 moved it to the text family above. + // `signature` / `qrcode` sat here as a measured OPEN DEFECT — their + // stored value IS the declared value, routinely a data-URI far past + // 255 chars, refused by both enforcing dialects — until #11875 gave + // the write seam their bound and moved them to the text family too.) // // A type that genuinely wants the bound belongs in the string-family // case above, named — never acquired by falling through to here. diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index a9af1a7b25..a958cca040 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -936,3 +936,100 @@ describe('validateRecord — number `scale` is enforced by rejection (#7501)', ( expect(err.message).toBe('Max hours per shift的小数位数不能超过 0 位(当前 1 位)'); }); }); + +/** + * #11875 (maintainer ruling 2026-08-25, option 1) — `signature` / `qrcode` + * join the bounded-string branch, so their declared `maxLength` / `minLength` + * finally BINDS at the write seam. This is the enforcement half of the ruling; + * the storage half (their column becomes TEXT, licensed by exactly this + * branch) is pinned in driver-sql's + * `sql-driver-11794-richtext-text-family.test.ts`. + * + * The branch now reads the spec's BOUNDED_STRING_FIELD_TYPES instead of a + * hand-copied type list, so the authoring seam (`FieldSchema` refuses the key + * outside the set) and this seam can no longer drift apart — the drift is how + * these two types spent a release with a declarable-nowhere/enforced-nowhere + * bound. + */ +describe('validateRecord — signature/qrcode maxLength binds at the write seam (#11875)', () => { + const schema = { + fields: { + sig: { type: 'signature', maxLength: 64 }, + qr: { type: 'qrcode', maxLength: 64 }, + }, + }; + + const fieldsOf = (data: Record, mode: 'insert' | 'update' = 'insert') => { + try { + validateRecord(schema, data, mode); + } catch (e) { + return (e as ValidationError).fields; + } + return null; + }; + + for (const field of ['sig', 'qr'] as const) { + it(`refuses a 100-char ${field} value with a field-named max_length envelope (the card's repro)`, () => { + const errs = fieldsOf({ [field]: 'y'.repeat(100) }); + expect(errs).not.toBeNull(); + expect(errs?.[0]).toMatchObject({ + field, + code: 'max_length', + constraint: { maxLength: 64, actual: 100 }, + }); + }); + + it(`accepts the boundary value on ${field} — exactly maxLength characters`, () => { + expect(fieldsOf({ [field]: 'y'.repeat(64) })).toBeNull(); + }); + + it(`refuses one past the boundary on ${field}`, () => { + const errs = fieldsOf({ [field]: 'y'.repeat(65) }); + expect(errs?.[0]).toMatchObject({ + field, + code: 'max_length', + constraint: { maxLength: 64, actual: 65 }, + }); + }); + } + + it('enforces on update as well as insert', () => { + const errs = fieldsOf({ sig: 'y'.repeat(100) }, 'update'); + expect(errs?.[0]).toMatchObject({ field: 'sig', code: 'max_length' }); + }); + + it('minLength travels with the branch, like every other member', () => { + const s = { fields: { sig: { type: 'signature', minLength: 10 } } }; + try { + validateRecord(s, { sig: 'short' }, 'insert'); + throw new Error('expected a ValidationError'); + } catch (e) { + expect((e as ValidationError).fields?.[0]).toMatchObject({ + field: 'sig', + code: 'min_length', + constraint: { minLength: 10, actual: 5 }, + }); + } + }); + + it('a signature/qrcode with NO declared bound accepts an ordinary data-URI (the defect this closes)', () => { + const s = { fields: { sig: { type: 'signature' }, qr: { type: 'qrcode' } } }; + const dataUri = `data:image/png;base64,${'A'.repeat(1000)}`; + expect(() => validateRecord(s, { sig: dataUri, qr: dataUri }, 'insert')).not.toThrow(); + }); + + it("controls: `secret` and `color` stay OUTSIDE the branch — the ruling's explicit carve-outs", () => { + // `secret` persists an opaque `sys_secret` ref (ADR-0100) and `color` is + // short by construction; the 2026-08-25 ruling names both as NOT covered. + // A hand-built runtime schema can still carry the key (authoring refuses + // it, this is not authored metadata) — the validator enforces nothing for + // them, exactly as before. + const s = { + fields: { + sec: { type: 'secret', maxLength: 4 }, + col: { type: 'color', maxLength: 4 }, + }, + }; + expect(() => validateRecord(s, { sec: 'longer-than-four', col: '#aabbcc' }, 'insert')).not.toThrow(); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 05a16d5046..c0ab420eef 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -47,6 +47,7 @@ import { isPlainRecord, ALL_OPERATORS, RETIRED_FILTER_OPERATORS, + BOUNDED_STRING_FIELD_TYPES, REFERENCE_VALUE_TYPES, FILE_REFERENCE_TYPES, STRUCTURED_JSON_TYPES, @@ -550,8 +551,21 @@ function validateOne( } } - // ── string types ──────────────────────────────────────────────── - if (t === 'text' || t === 'textarea' || t === 'email' || t === 'url' || t === 'phone' || t === 'password' || t === 'markdown' || t === 'html' || t === 'richtext' || t === 'code') { + // ── string types (BOUNDED_STRING_FIELD_TYPES) ─────────────────── + // The membership is the SPEC'S set, not a local list, on purpose (#11875): + // this branch is the write seam that makes a declared `maxLength` / + // `minLength` actually bind, and `FieldSchema` refuses the keys on any type + // outside the same set — so the two seams reading one constant is what + // keeps declared = enforced from drifting (they were two hand-copies until + // `signature`/`qrcode` joined and only one copy moved). The set held + // text / textarea / email / url / phone / password / markdown / html / + // richtext / code at #11566; `signature` and `qrcode` joined at #11875 + // (maintainer ruling 2026-08-25, option 1) — their stored value is the + // author's own string (routinely a data URI far past 255 chars), and this + // branch enforcing their bound is what licenses their unbounded TEXT column + // in driver-sql (the #11794 invariant). The email/url/phone format checks + // below stay per-type conditions inside the branch. + if (BOUNDED_STRING_FIELD_TYPES.has(t)) { const s = typeof value === 'string' ? value : String(value); if (def.maxLength !== undefined && s.length > def.maxLength) { return fail('max_length', { maxLength: def.maxLength, actual: s.length }); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 2ae7204359..36629eb983 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -36,12 +36,14 @@ export const fieldForm = defineForm({ { field: 'placeholder', helpText: 'Hint text shown inside the empty input (disappears once a value is entered); use inlineHelpText for always-visible help' }, // Text field options { field: 'minLength', visibleWhen: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Minimum character length' }, - // #11566 — `maxLength` is shown for exactly the ten bounded-string - // types the schema accepts it on and the write-time validator enforces - // it for (BOUNDED_STRING_FIELD_TYPES; maintainer ruling 2026-08-24). + // #11566 — `maxLength` is shown for exactly the bounded-string types + // the schema accepts it on and the write-time validator enforces it + // for (BOUNDED_STRING_FIELD_TYPES; maintainer ruling 2026-08-24). // This list used to be a third opinion (3 types here, 9 in // object.form, 10 at the validator); it converged to the validator's. - { field: 'maxLength', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code']", helpText: 'Maximum character length' }, + // #11875 added `signature`/`qrcode` to the set (the write seam now + // enforces their declared bound); this visibleWhen moves with it. + { field: 'maxLength', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']", helpText: 'Maximum character length' }, // Number field options { field: 'min', visibleWhen: "data.type == 'number' || data.type == 'currency'", helpText: 'Minimum value' }, { field: 'max', visibleWhen: "data.type == 'number' || data.type == 'currency'", helpText: 'Maximum value' }, diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index d4bd952b0d..057b553606 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -367,8 +367,9 @@ describe('FieldSchema', () => { * before #11431 taught the consumer to defend itself). Applicability: the * key was on the BASE schema — authorable on `boolean`/`lookup`/ * `autonumber` where nothing bounded is stored — and now converges to the - * write-time validator's ten bounded-string types - * (BOUNDED_STRING_FIELD_TYPES). + * write-time validator's bounded-string types + * (BOUNDED_STRING_FIELD_TYPES; ten at #11566, `signature`/`qrcode` + * joined in #11875 when the write seam gained their bound). */ describe('malformed or misplaced maxLength declarations are refused at authoring (#11566)', () => { const shapeCases: Array<[value: number, code: string]> = [ @@ -394,11 +395,13 @@ describe('FieldSchema', () => { // One representative per family the base-schema placement wrongly // accepted: logic, numeric, temporal, selection, relational, - // runtime-owned, derived, structured — plus `secret`, the near-miss - // (stores a ciphertext handle, deliberately outside the ten). + // runtime-owned, derived, structured — plus `secret` and `color`, the + // near-misses the #11875 ruling explicitly left OUT of the set (`secret` + // stores a ciphertext handle per ADR-0100; `color` is short by + // construction). const wrongTypes = [ 'boolean', 'number', 'date', 'select', 'lookup', 'autonumber', - 'formula', 'json', 'secret', + 'formula', 'json', 'secret', 'color', ] as const; for (const type of wrongTypes) { it(`refuses maxLength on type: '${type}' with a custom issue at [maxLength]`, () => { @@ -417,12 +420,15 @@ describe('FieldSchema', () => { }); } - it('accepts a positive-integer maxLength on every bounded-string type (the validator\'s ten)', () => { - const ten = [ + it('accepts a positive-integer maxLength on every bounded-string type (the validator\'s twelve)', () => { + // Ten at #11566; `signature` / `qrcode` joined in #11875 (maintainer + // ruling 2026-08-25, option 1) — the write seam enforces their bound, + // so the declaration is no longer inert and the authoring seam admits it. + const twelve = [ 'text', 'textarea', 'email', 'url', 'phone', 'password', - 'markdown', 'html', 'richtext', 'code', + 'markdown', 'html', 'richtext', 'code', 'signature', 'qrcode', ] as const; - for (const type of ten) { + for (const type of twelve) { const result = FieldSchema.safeParse({ name: 'f', label: 'F', type, maxLength: 255, }); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index e50f288863..b77c10416e 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -115,14 +115,26 @@ export type FieldType = z.input; * show the key for exactly this set — declared converges to enforced * (ADR-0078). * + * `signature` / `qrcode` joined in #11875 (maintainer ruling 2026-08-25, + * option 1): their stored value IS the author's own string and routinely far + * past 255 characters (a data-URI PNG for `signature`), and the write-time + * validator now enforces their declared bound — declared = enforced holds in + * both directions, which is also what licenses their unbounded TEXT column in + * `driver-sql` (the #11794 invariant: TEXT is permitted exactly because the + * write seam enforces the declared `maxLength`). + * * Deliberately NOT here: `secret` (stored ciphertext handle — the authored - * value's length is not what the column holds), `select`/`multiselect` - * (bounded by their options, not by a character count), `json`/`code`-adjacent - * structured types other than `code` itself, and every non-string type. + * value's length is not what the column holds; ADR-0100 — explicitly outside + * the #11875 ruling), `color` (short by construction — same ruling), + * `select`/`multiselect` (bounded by their options, not by a character count), + * `json`/`code`-adjacent structured types other than `code` itself, and every + * non-string type. */ export const BOUNDED_STRING_FIELD_TYPES: ReadonlySet = new Set([ 'text', 'textarea', 'email', 'url', 'phone', 'password', 'markdown', 'html', 'richtext', 'code', + // #11875 — the write seam enforces these two's declared bound (see above). + 'signature', 'qrcode', ] as const satisfies readonly FieldType[]); /** @@ -884,7 +896,7 @@ export const FieldSchema = lazySchema(() => { // server accepts, at severity error/destructive, before #11431 taught the // consumer to defend itself). Which TYPES may author the key is the // superRefine below (BOUNDED_STRING_FIELD_TYPES). - maxLength: z.number().int().min(1).optional().describe('Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code.'), + maxLength: z.number().int().min(1).optional().describe('Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode.'), minLength: z.number().optional().describe('Min character length'), /** Number Constraints */ @@ -1639,21 +1651,26 @@ export const FieldSchema = lazySchema(() => { // `maxLength` is only authorable on types that store a bounded string. // The key sat on the BASE schema, so it was legal on `boolean` / `lookup` / // `autonumber` / `formula` — types where it describes nothing that is - // stored — while the write-time validator has only ever enforced it on the - // BOUNDED_STRING_FIELD_TYPES ten. Declared converges to enforced + // stored — while the write-time validator has only ever enforced it on + // exactly the BOUNDED_STRING_FIELD_TYPES members (ten then; `signature` / + // `qrcode` joined in #11875). Declared converges to enforced // (ADR-0078): the inert declaration is refused at the authoring seam, where // the fix is one keystroke away, instead of parsing cleanly and doing // nothing (the declared-but-inert shape that hides AI-authored metadata // errors). `maxLength` has no schema default, so `undefined` here always // means "not authored" — a field without the key can never fire this. + // + // The message enumerates the set ITSELF rather than a prose copy of it — + // #11875 found the hand-written enumeration already one revision behind the + // set it described, which is the #12017 two-copies failure shape. if (field.maxLength !== undefined && !BOUNDED_STRING_FIELD_TYPES.has(field.type)) { ctx.addIssue({ code: 'custom', path: ['maxLength'], message: `\`maxLength\` is only valid on field types that store a bounded string — ` + - `'text', 'textarea', 'email', 'url', 'phone', 'password', 'markdown', 'html', ` + - `'richtext', 'code' — and this field is \`${field.type}\`: its stored value has no ` + + `${[...BOUNDED_STRING_FIELD_TYPES].map((t) => `'${t}'`).join(', ')} — ` + + `and this field is \`${field.type}\`: its stored value has no ` + 'character length for the bound to constrain, so the declaration would parse and ' + 'enforce nothing (the write-time validator applies `maxLength` to exactly those ' + 'types). Drop the key, or use a bounded string type.', diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 7536373b57..32b93b2aa9 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -122,11 +122,13 @@ export const objectForm = defineForm({ { field: 'placeholder', type: 'text', helpText: 'Hint text shown inside the empty input; disappears once a value is entered' }, // Text constraints - // #11566 — aligned to the ten bounded-string types the schema - // accepts `maxLength` on (BOUNDED_STRING_FIELD_TYPES — the - // write-time validator's list; maintainer ruling 2026-08-24). - // `code` was the one this list was missing. - { field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code']" }, + // #11566 — aligned to the bounded-string types the schema accepts + // `maxLength` on (BOUNDED_STRING_FIELD_TYPES — the write-time + // validator's list; maintainer ruling 2026-08-24). `code` was the + // one this list was missing. #11875 added `signature`/`qrcode` + // (the write seam now enforces their declared bound); this + // visibleWhen moves with the set. + { field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']" }, { field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" }, // Numeric constraints