Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/maxlength-bounded-string-only.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/data/field.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) |
Expand Down
9 changes: 8 additions & 1 deletion packages/lint/src/validate-predicate-path-refs.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/data/field.form.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' },
Expand Down
81 changes: 81 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>;
expect('maxLength' in result).toBe(false);
}
});
});
});

describe('useGrouping — number-field digit-grouping presentation hint (#7768)', () => {
Expand Down
58 changes: 57 additions & 1 deletion packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,6 +101,30 @@ export const FieldType = z.enum([

export type FieldType = z.input<typeof FieldType>;

/**
* 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<string> = 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
Expand DownExpand Up@@ -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 */
Expand DownExpand Up@@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/spec/src/data/object.form.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading