diff --git a/.changeset/maxlength-bounded-string-only.md b/.changeset/maxlength-bounded-string-only.md new file mode 100644 index 0000000000..37a28a18c8 --- /dev/null +++ b/.changeset/maxlength-bounded-string-only.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': minor +--- + +`FieldSchema.maxLength` tightens on both axes (#11566, maintainer ruling 2026-08-24). Shape: the key is now `z.number().int().min(1)`, so `maxLength: 0`, negative and non-integer declarations are refused at parse — none of them is a character length, and `maxLength: 0` measurably sent the SQL schema-drift planner asking for `varchar(0)` DDL at severity error/destructive before #11431 taught that consumer to defend itself. Applicability: the key sat on the base schema and was authorable on every field type; it is now refused on any type that does not store a bounded string, and accepted on exactly the write-time validator's ten — `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext`, `code` — the only one of the three previously-disagreeing lists with a measured reader, exported as `BOUNDED_STRING_FIELD_TYPES`. + +What newly gets rejected: `maxLength: 0` / negative / non-integer on any type, and `maxLength` with any value on every non-bounded-string type (`boolean`, `number`, `date`, `select`, `lookup`, `autonumber`, `formula`, `json`, `secret`, …). Both rejections are prescriptive — the message names the legal shape, the legal type set, and the fix. The two authoring forms (`field.form.ts`, previously three types; `object.form.ts`, previously nine) converge on the same ten. Already-legal declarations (a positive-integer `maxLength` on a bounded-string type) round-trip byte-identically, and absence stays absence — no default materializes. + +The ADR-0087 ledger entry for this narrowing is deferred to #11950 (the migration registry file was serialized behind an in-flight change when this landed), following the #8321 `scale`/`precision` template: a mechanical delete of malformed or misplaced values plus a semantic re-declare prescription at the next major boundary. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 71737f1cea..634977cbfc 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** | `number` | optional | Max character length | +| **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. | | **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/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 8d1482cf65..c337cd16b5 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -598,7 +598,14 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { for (const value of Object.values(rec)) rewrite(value); }; rewrite(corrupted.views); - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(47); + // The count tracks the CORPUS, not an issue: 44 today because #11566 + // (PR #11989) respelled the field form's `maxLength` row from a 3-way + // `data.type == '…'` chain to one `data.type in […]` list while aligning + // it to the ten bounded-string types — 3 fewer `==` literal comparisons, + // and `in`-list literals are deliberately not this rule's (see the anchor + // note above). The pre-#11566 measurement was 47 and stays 47 — history, + // not the census. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(44); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index c123d5c45d..ea66310014 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -43,6 +43,7 @@ "AutonumberFormatSource (interface)", "AutonumberToken (type)", "BOOLEAN_VALUE_TYPES (const)", + "BOUNDED_STRING_FIELD_TYPES (const)", "BUILTIN_DRIVER_IDS (const)", "BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE (const)", "BULK_WRITE_HOOK_DISPATCH_CONTRACT (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 1b528d8874..0a4943b480 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -43,6 +43,7 @@ "AutonumberFormatSource": "src/data/autonumber-format.ts#AutonumberFormatSource (interface)", "AutonumberToken": "src/data/autonumber-format.ts#AutonumberToken (type)", "BOOLEAN_VALUE_TYPES": "src/data/field-value.zod.ts#BOOLEAN_VALUE_TYPES (const)", + "BOUNDED_STRING_FIELD_TYPES": "src/data/field.zod.ts#BOUNDED_STRING_FIELD_TYPES (const)", "BUILTIN_DRIVER_IDS": "src/data/driver/config-registry.zod.ts#BUILTIN_DRIVER_IDS (const)", "BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE": "src/data/bulk-write-hook-conformance.ts#BULK_PER_ROW_HOOK_LIMIT_ERROR_CODE (const)", "BULK_WRITE_HOOK_DISPATCH_CONTRACT": "src/data/bulk-write-hook-conformance.ts#BULK_WRITE_HOOK_DISPATCH_CONTRACT (const)", diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 5349ca0069..2ae7204359 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -36,7 +36,12 @@ 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' }, - { field: 'maxLength', visibleWhen: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Maximum 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). + // 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' }, // 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 b1cc7e6447..d4bd952b0d 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -357,6 +357,87 @@ describe('FieldSchema', () => { if (result.success) expect(result.data.currencyConfig?.precision).toBe(10); }); }); + + /** + * #11566 (maintainer ruling 2026-08-24) — `maxLength` tightens on both + * axes. Shape: a character length is a positive integer, so `0` / `-5` / + * `12.5` are refused at the producer (`z.number().int().min(1)` — the + * #8321 house pattern one field below; `maxLength: 0` measurably sent + * schema-drift planning `varchar(0)` DDL, at severity error/destructive, + * 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). + */ + describe('malformed or misplaced maxLength declarations are refused at authoring (#11566)', () => { + const shapeCases: Array<[value: number, code: string]> = [ + [0, 'too_small'], // the issue repro: varchar(0) is not a bound + [-5, 'too_small'], + [12.5, 'invalid_type'], // non-integer count — the varchar(12.5) repro + ]; + for (const [value, code] of shapeCases) { + it(`refuses maxLength: ${value} on a text field with a ${code} issue at [maxLength]`, () => { + const result = FieldSchema.safeParse({ + name: 'title', label: 'Title', type: 'text', maxLength: value, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path[0] === 'maxLength'); + expect(issue?.code).toBe(code); + // Message substance, not just a throw: the refusal names what a + // legal value looks like (int / >=1), so an AI author can fix it. + expect(issue?.message).toMatch(code === 'invalid_type' ? /expected int/ : />=1/); + } + }); + } + + // 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). + const wrongTypes = [ + 'boolean', 'number', 'date', 'select', 'lookup', 'autonumber', + 'formula', 'json', 'secret', + ] as const; + for (const type of wrongTypes) { + it(`refuses maxLength on type: '${type}' with a custom issue at [maxLength]`, () => { + const result = FieldSchema.safeParse({ + name: 'f', label: 'F', type, maxLength: 50, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path[0] === 'maxLength'); + expect(issue?.code).toBe('custom'); + // The refusal names the legal set and the offending type, so an + // AI author can fix the declaration without leaving the message. + expect(issue?.message).toMatch(/bounded string/); + expect(issue?.message).toContain(`\`${type}\``); + } + }); + } + + it('accepts a positive-integer maxLength on every bounded-string type (the validator\'s ten)', () => { + const ten = [ + 'text', 'textarea', 'email', 'url', 'phone', 'password', + 'markdown', 'html', 'richtext', 'code', + ] as const; + for (const type of ten) { + const result = FieldSchema.safeParse({ + name: 'f', label: 'F', type, maxLength: 255, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.maxLength).toBe(255); + } + }); + + it('absent maxLength stays absent — no default materializes, on any type', () => { + for (const type of ['text', 'boolean', 'lookup'] as const) { + const result = FieldSchema.parse({ name: 'f', label: 'F', type }) as Record; + expect('maxLength' in result).toBe(false); + } + }); + }); }); describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 5320a80a86..e50f288863 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -101,6 +101,30 @@ export const FieldType = z.enum([ export type FieldType = z.input; +/** + * Field types whose stored value is a BOUNDED STRING — the set on which a + * `maxLength` / `minLength` character bound describes something that is + * actually stored (#11566, maintainer ruling 2026-08-24). + * + * This is the write-time validator's own enforcement list (objectql + * `record-validator.ts`, the string-types branch) promoted to the protocol: + * three lists used to disagree (field.form showed the key for 3 types, + * object.form for 9, the validator enforced 10), and the validator's ten is + * the only one with a measured reader. `FieldSchema` refuses `maxLength` + * outside this set (see the superRefine below), and the two authoring forms + * show the key for exactly this set — declared converges to enforced + * (ADR-0078). + * + * 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. + */ +export const BOUNDED_STRING_FIELD_TYPES: ReadonlySet = new Set([ + 'text', 'textarea', 'email', 'url', 'phone', 'password', + 'markdown', 'html', 'richtext', 'code', +] as const satisfies readonly FieldType[]); + /** * Field types whose stored value the RUNTIME owns outright — issued by the * engine (or the driver's persistent sequence), never supplied by a caller on @@ -853,7 +877,14 @@ export const FieldSchema = lazySchema(() => { defaultValue: z.unknown().optional().describe('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.'), /** Text/String Constraints */ - maxLength: z.number().optional().describe('Max character length'), + // #11566 — a character length is a positive integer, so `0`, `-5` and `12.5` + // are refused at the producer (house pattern: the #8321 `precision`/`scale` + // refusal below; same "a malformed count has no defined meaning" argument — + // `maxLength: 0` measurably sent schema-drift planning `varchar(0)` DDL no + // 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.'), minLength: z.number().optional().describe('Min character length'), /** Number Constraints */ @@ -1604,6 +1635,31 @@ export const FieldSchema = lazySchema(() => { }); } + // [#11566] (maintainer ruling 2026-08-24 — 「四维分析一致的,接手你的建议。」): + // `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 + // (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. + 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 ` + + '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.', + }); + } + // #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level // `precision` key doubles as the currency display width — objectui's // CurrencyField reads it, and objectui#4361 pinned authored-precision-wins diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 70354586d4..7536373b57 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -122,7 +122,11 @@ export const objectForm = defineForm({ { field: 'placeholder', type: 'text', helpText: 'Hint text shown inside the empty input; disappears once a value is entered' }, // Text constraints - { field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" }, + // #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']" }, { field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" }, // Numeric constraints