@wildo-ai/zod-decorators 1.1.4 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/currencies.shared.schemas.d.ts.map +1 -1
- package/dist/esm/currencies.shared.schemas.js.map +1 -1
- package/dist/esm/files/agent-file-formats.d.ts +51 -37
- package/dist/esm/files/agent-file-formats.d.ts.map +1 -1
- package/dist/esm/files/agent-file-formats.js +54 -47
- package/dist/esm/files/agent-file-formats.js.map +1 -1
- package/dist/esm/files/extension-nature-map.d.ts.map +1 -1
- package/dist/esm/files/mime-registry.d.ts.map +1 -1
- package/dist/esm/files/unified-registry.d.ts +2 -2
- package/dist/esm/files/unified-registry.d.ts.map +1 -1
- package/dist/esm/files/unified-registry.js +1 -1
- package/dist/esm/files/unified-registry.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/testing/decorated-test-schemas.d.ts.map +1 -1
- package/dist/esm/testing.d.ts +11 -0
- package/dist/esm/testing.d.ts.map +1 -1
- package/dist/esm/testing.js +11 -0
- package/dist/esm/testing.js.map +1 -1
- package/dist/esm/zod-address.d.ts +146 -0
- package/dist/esm/zod-address.d.ts.map +1 -0
- package/dist/esm/zod-address.js +176 -0
- package/dist/esm/zod-address.js.map +1 -0
- package/dist/esm/zod-bigdecimal.d.ts.map +1 -1
- package/dist/esm/zod-code.d.ts +2 -0
- package/dist/esm/zod-code.d.ts.map +1 -1
- package/dist/esm/zod-code.js +4 -4
- package/dist/esm/zod-code.js.map +1 -1
- package/dist/esm/zod-color.d.ts.map +1 -1
- package/dist/esm/zod-decorators.types.d.ts +311 -29
- package/dist/esm/zod-decorators.types.d.ts.map +1 -1
- package/dist/esm/zod-decorators.types.js +177 -8
- package/dist/esm/zod-decorators.types.js.map +1 -1
- package/dist/esm/zod-duration.d.ts +2 -0
- package/dist/esm/zod-duration.d.ts.map +1 -1
- package/dist/esm/zod-duration.js +4 -4
- package/dist/esm/zod-duration.js.map +1 -1
- package/dist/esm/zod-file.d.ts +70 -4
- package/dist/esm/zod-file.d.ts.map +1 -1
- package/dist/esm/zod-file.js +92 -75
- package/dist/esm/zod-file.js.map +1 -1
- package/dist/esm/zod-money.d.ts +2 -0
- package/dist/esm/zod-money.d.ts.map +1 -1
- package/dist/esm/zod-money.js +6 -6
- package/dist/esm/zod-money.js.map +1 -1
- package/dist/esm/zod-phone.d.ts +8 -0
- package/dist/esm/zod-phone.d.ts.map +1 -1
- package/dist/esm/zod-phone.js +6 -1
- package/dist/esm/zod-phone.js.map +1 -1
- package/dist/esm/zod-range.d.ts.map +1 -1
- package/dist/esm/zod-utils.shared.d.ts +343 -12
- package/dist/esm/zod-utils.shared.d.ts.map +1 -1
- package/dist/esm/zod-utils.shared.js +1004 -90
- package/dist/esm/zod-utils.shared.js.map +1 -1
- package/dist/esm/zod-versioned.d.ts.map +1 -1
- package/dist/esm/zod-versioned.js.map +1 -1
- package/dist/esm/zod.module-override.d.ts +29 -1
- package/dist/esm/zod.module-override.d.ts.map +1 -1
- package/dist/esm/zod.module-override.js +67 -8
- package/dist/esm/zod.module-override.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -7
- package/dist/esm/companion-exports.d.ts +0 -8
- package/dist/esm/companion-exports.d.ts.map +0 -1
- package/dist/esm/companion-exports.js +0 -8
- package/dist/esm/companion-exports.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { BooleanEditMode, EnumEditMode, NumberEditMode, StringEditMode } from './zod-decorators.types.js';
|
|
3
|
-
import { DEFAULT_FOREIGN_KEY_DISPLAY_MODE, FileGenerationSource, FieldSchemaType, FormFieldComponent, RangeType, SCHEMA_METADATA, ZodDecorator } from './zod-decorators.types.js';
|
|
2
|
+
import { BooleanEditMode, DisplayEmptyReason, EnumEditMode, EnumMultipleEditMode, NumberEditMode, StringEditMode } from './zod-decorators.types.js';
|
|
3
|
+
import { DEFAULT_FOREIGN_KEY_DISPLAY_MODE, FileGenerationSource, FieldSchemaType, FormFieldComponent, RangeType, SCHEMA_METADATA, ZodDecorator, RequestDtoWriteSemantics } from './zod-decorators.types.js';
|
|
4
4
|
import { z_range } from './zod-range.js';
|
|
5
5
|
import { addDecorators, getMetadataRecursive } from './zod.module-override.js';
|
|
6
6
|
// Type guard to check if a schema has Zod v4 internals
|
|
@@ -97,6 +97,84 @@ export function unwrapSchema(schema) {
|
|
|
97
97
|
return schema;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The same PATCH rule, one level down: inside a NESTED object field, a key the caller omits must not be
|
|
102
|
+
* re-materialised from its `.default()` either.
|
|
103
|
+
*
|
|
104
|
+
* `toPatchOptionalField` peels the OUTER default of a top-level field, but an object field's own keys
|
|
105
|
+
* kept theirs, so `{ privacy: { privacyLevel: 'minimal' } }` parsed to
|
|
106
|
+
* `{ privacy: { privacyLevel: 'minimal', consentedPurposes: [] } }` — a write the caller never made, which
|
|
107
|
+
* a downstream hook cannot tell apart from an explicit empty set (#534 review F1: a privacy-level change
|
|
108
|
+
* read as a consent withdrawal). Zod 4 applies a default even inside `.optional()`, so the default must
|
|
109
|
+
* be peeled, not wrapped.
|
|
110
|
+
*
|
|
111
|
+
* (`privacyLevel` itself was removed in #1280 — it was a stored preference nothing read. The example is
|
|
112
|
+
* kept in its original terms because it is the account of a defect that actually happened; the rule it
|
|
113
|
+
* motivates is about ANY nested key with a default, and is unchanged.)
|
|
114
|
+
*
|
|
115
|
+
* Scope, deliberately narrow:
|
|
116
|
+
* - nested PLAIN object fields, recursively, through `optional` / `nullable` wrappers only. An object
|
|
117
|
+
* nested inside a union, discriminated union, record, intersection, pipe/transform, `readonly` or `lazy`
|
|
118
|
+
* is NOT peeled and keeps re-materialising its defaults — narrower than it could be, never wrong;
|
|
119
|
+
* - an object carrying its own refinement is NOT peeled: a `.refine()` / `.superRefine()` may read a
|
|
120
|
+
* defaulted key, and would see `undefined` (or throw) once that default stopped applying;
|
|
121
|
+
* - a nested key WITH a default becomes optional without it; a nested key WITHOUT one keeps its declared
|
|
122
|
+
* optionality — a replaced nested object must still carry what it requires;
|
|
123
|
+
* - ARRAYS are left alone: an array element is sent whole, and its defaults describe a new element;
|
|
124
|
+
* - the object is rebuilt with Zod's own `clone(def)`, so `strict` / `catchall` and refinements survive;
|
|
125
|
+
* decorator metadata AND `z.globalRegistry` metadata (`.describe()`, `.meta()` — what MCP tool input
|
|
126
|
+
* schemas are generated from) are copied onto every rebuilt schema, and inner schemas are SHARED,
|
|
127
|
+
* never mutated, so the source schema is untouched.
|
|
128
|
+
*/
|
|
129
|
+
function withoutNestedObjectDefaults(schema) {
|
|
130
|
+
const type = getZodType(schema);
|
|
131
|
+
if (type === 'optional' || type === 'nullable') {
|
|
132
|
+
const inner = getZodDefSchemaProperty(schema, 'innerType');
|
|
133
|
+
if (!inner)
|
|
134
|
+
return schema;
|
|
135
|
+
const rebuiltInner = withoutNestedObjectDefaults(inner);
|
|
136
|
+
if (rebuiltInner === inner)
|
|
137
|
+
return schema;
|
|
138
|
+
return copySchemaMetadata(schema, type === 'optional' ? rebuiltInner.optional() : rebuiltInner.nullable());
|
|
139
|
+
}
|
|
140
|
+
if (type !== 'object')
|
|
141
|
+
return schema;
|
|
142
|
+
const def = schema._zod.def;
|
|
143
|
+
if (Array.isArray(def.checks) && def.checks.length > 0)
|
|
144
|
+
return schema;
|
|
145
|
+
const shape = schema.shape;
|
|
146
|
+
const nextShape = {};
|
|
147
|
+
let changed = false;
|
|
148
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
149
|
+
let next;
|
|
150
|
+
if (isZodDefault(field)) {
|
|
151
|
+
const peeled = getZodDefSchemaProperty(field, 'innerType');
|
|
152
|
+
next = peeled ? copySchemaMetadata(field, withoutNestedObjectDefaults(peeled).optional()) : field;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
next = withoutNestedObjectDefaults(field);
|
|
156
|
+
}
|
|
157
|
+
if (next !== field)
|
|
158
|
+
changed = true;
|
|
159
|
+
nextShape[key] = next;
|
|
160
|
+
}
|
|
161
|
+
if (!changed)
|
|
162
|
+
return schema;
|
|
163
|
+
const rebuilt = schema.clone({ ...def, shape: nextShape });
|
|
164
|
+
return copySchemaMetadata(schema, rebuilt);
|
|
165
|
+
}
|
|
166
|
+
/** Carry decorator metadata from a source wrapper onto a rebuilt one; the source is never mutated. */
|
|
167
|
+
function copySchemaMetadata(source, target) {
|
|
168
|
+
const registered = z.globalRegistry.get(source);
|
|
169
|
+
if (registered && !z.globalRegistry.has(target))
|
|
170
|
+
z.globalRegistry.add(target, registered);
|
|
171
|
+
const metadata = source[SCHEMA_METADATA];
|
|
172
|
+
if (metadata) {
|
|
173
|
+
const record = target;
|
|
174
|
+
record[SCHEMA_METADATA] = { ...(record[SCHEMA_METADATA] ?? {}), ...metadata };
|
|
175
|
+
}
|
|
176
|
+
return target;
|
|
177
|
+
}
|
|
100
178
|
/**
|
|
101
179
|
* Make a field OPTIONAL for a PATCH-style UPDATE DTO, peeling any OUTER
|
|
102
180
|
* `.default()` wrapper first.
|
|
@@ -137,11 +215,13 @@ export function unwrapSchema(schema) {
|
|
|
137
215
|
export function toPatchOptionalField(fieldSchema) {
|
|
138
216
|
const admitsNullClear = fieldAdmitsAddedNullClear(fieldSchema);
|
|
139
217
|
if (!isZodDefault(fieldSchema)) {
|
|
140
|
-
|
|
218
|
+
const nestedPatched = withoutNestedObjectDefaults(fieldSchema);
|
|
219
|
+
return admitsNullClear ? nestedPatched.nullable().optional() : nestedPatched.optional();
|
|
141
220
|
}
|
|
142
|
-
const
|
|
143
|
-
if (!
|
|
221
|
+
const rawInnerType = getZodDefSchemaProperty(fieldSchema, 'innerType');
|
|
222
|
+
if (!rawInnerType)
|
|
144
223
|
return fieldSchema.optional();
|
|
224
|
+
const innerType = withoutNestedObjectDefaults(rawInnerType);
|
|
145
225
|
// A defaulted field is never null-clearable (see `fieldAdmitsAddedNullClear`), so the peeled
|
|
146
226
|
// inner is made optional WITHOUT a nullable wrapper.
|
|
147
227
|
const optionalInner = innerType.optional();
|
|
@@ -219,6 +299,69 @@ export function normalizeClearedRequestDtoValue(fieldSchema, value) {
|
|
|
219
299
|
export function normalizeClearedMainSchemaValue(fieldSchema, value) {
|
|
220
300
|
return normalizeClearedValue(fieldSchema, value, mainSchemaFieldIsClearable);
|
|
221
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Mark an UPDATE request DTO object as written PATCH style (see `RequestDtoWriteSemantics`).
|
|
304
|
+
*
|
|
305
|
+
* The mark lives in the `SCHEMA_METADATA` bag because that bag is what every DTO rebuild carries
|
|
306
|
+
* across (network-boundary coercion, templatable-file stripping, nested-relationship extension). It
|
|
307
|
+
* is written COPY-ON-WRITE: rebuilds share one bag object between instances, so mutating it would
|
|
308
|
+
* mark schemas that were never PATCH roots.
|
|
309
|
+
*/
|
|
310
|
+
export function markPatchRequestDtoObject(schema) {
|
|
311
|
+
const record = schema;
|
|
312
|
+
record[SCHEMA_METADATA] = { ...(record[SCHEMA_METADATA] ?? {}), requestDtoWriteSemantics: RequestDtoWriteSemantics.PATCH };
|
|
313
|
+
return schema;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Does this object follow the PATCH write rule — absent key untouched, `null` clears?
|
|
317
|
+
*
|
|
318
|
+
* Looks through optional, nullable and default wrappers. A UNION answers `true` when ANY option is a
|
|
319
|
+
* PATCH object: a nested child's items are a union of an update branch (PATCH) and a create branch
|
|
320
|
+
* (written whole), and a field cannot tell which one it is in. `true` keeps the stricter rule, under
|
|
321
|
+
* which a required field stays unclearable; the other answer would let one read as clearable in the
|
|
322
|
+
* update branch, where clearing it would be a silent no-op.
|
|
323
|
+
*/
|
|
324
|
+
export function objectFollowsPatchWriteRule(schema) {
|
|
325
|
+
if (!schema)
|
|
326
|
+
return false;
|
|
327
|
+
const metadata = schema[SCHEMA_METADATA];
|
|
328
|
+
if (metadata?.requestDtoWriteSemantics === RequestDtoWriteSemantics.PATCH)
|
|
329
|
+
return true;
|
|
330
|
+
const type = getZodType(schema);
|
|
331
|
+
if (type === 'optional' || type === 'nullable' || type === 'default') {
|
|
332
|
+
const innerSchema = getZodDefSchemaProperty(schema, 'innerType');
|
|
333
|
+
return innerSchema ? objectFollowsPatchWriteRule(innerSchema) : false;
|
|
334
|
+
}
|
|
335
|
+
if (type === 'union') {
|
|
336
|
+
const options = getZodDefArrayProperty(schema, 'options');
|
|
337
|
+
return (options ?? []).some((option) => objectFollowsPatchWriteRule(option));
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Is "clear it" meaningful for a field inside an object that is written WHOLE — an object value
|
|
343
|
+
* nested in a field, an array item, or a CREATE request DTO?
|
|
344
|
+
*
|
|
345
|
+
* The declaration answers it, exactly as on a main schema: absence or null allowed → clearable, required
|
|
346
|
+
* or defaulted → not. `requestDtoFieldIsClearable` ("accepts null") is the wrong question here: only a
|
|
347
|
+
* PATCH root's direct fields get a null admitted, so it reported every nested optional field
|
|
348
|
+
* unclearable, and an emptied nested address could never be saved (#989).
|
|
349
|
+
*/
|
|
350
|
+
export function valueObjectFieldIsClearable(fieldSchema) {
|
|
351
|
+
return mainSchemaFieldIsClearable(fieldSchema);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* `normalizeClearedFieldValue` for a field inside an object written WHOLE. The cleared value is ABSENCE
|
|
355
|
+
* (`undefined`), or `null` only for a field that accepts null.
|
|
356
|
+
*
|
|
357
|
+
* Absence, because nothing else is safe there: the persistence layer removes only TOP-LEVEL cleared
|
|
358
|
+
* fields, so a `null` nested in a stored object would be persisted as `null` and then fail the main
|
|
359
|
+
* schema's `.optional()` on the next read.
|
|
360
|
+
*/
|
|
361
|
+
export function normalizeClearedValueObjectFieldValue(fieldSchema, value) {
|
|
362
|
+
const normalized = normalizeClearedValue(fieldSchema, value, valueObjectFieldIsClearable);
|
|
363
|
+
return normalized === null && !fieldSchema.safeParse(null).success ? undefined : normalized;
|
|
364
|
+
}
|
|
222
365
|
function normalizeClearedValue(fieldSchema, value, isClearable) {
|
|
223
366
|
if (!isEmptyFieldGesture(value))
|
|
224
367
|
return value;
|
|
@@ -380,9 +523,14 @@ export function isZodArray(schema) {
|
|
|
380
523
|
* validate against. `reset_to_defaults` depended on exactly such a constraint to reject a request
|
|
381
524
|
* naming no preference block, and without it the route answered HTTP 200 having written nothing.
|
|
382
525
|
*
|
|
383
|
-
* Native `.clone()` re-instantiates from the schema's own definition, so checks
|
|
384
|
-
*
|
|
385
|
-
*
|
|
526
|
+
* Native `.clone()` re-instantiates from the schema's own definition, so checks and error maps
|
|
527
|
+
* survive by construction rather than by a copy loop that the next Zod version could outgrow.
|
|
528
|
+
*
|
|
529
|
+
* `z.globalRegistry` metadata (`.describe()`, `.meta()`) does NOT come with it: Zod links a clone to
|
|
530
|
+
* its source, which is how a clone inherits that metadata, only when no new definition is passed,
|
|
531
|
+
* and this always passes one. Measured, 2026-09-26: a billing-state `invoices` array lost its
|
|
532
|
+
* description to date coercion, so the API reference published it undescribed (#1625). It is copied
|
|
533
|
+
* explicitly below, without `id`, which names one schema and which Zod refuses to register twice.
|
|
386
534
|
*
|
|
387
535
|
* @param arraySchema - the array being rewritten. Returned untouched when it is not an array or
|
|
388
536
|
* carries no element, so callers do not need to pre-check.
|
|
@@ -396,7 +544,14 @@ export function withArrayElement(arraySchema, element) {
|
|
|
396
544
|
if (!def) {
|
|
397
545
|
return arraySchema;
|
|
398
546
|
}
|
|
399
|
-
|
|
547
|
+
const rebuilt = arraySchema.clone({ ...def, element });
|
|
548
|
+
const registered = z.globalRegistry.get(arraySchema);
|
|
549
|
+
if (registered !== undefined && !z.globalRegistry.has(rebuilt)) {
|
|
550
|
+
const { id: _id, ...carried } = registered;
|
|
551
|
+
if (Object.keys(carried).length > 0)
|
|
552
|
+
z.globalRegistry.add(rebuilt, carried);
|
|
553
|
+
}
|
|
554
|
+
return rebuilt;
|
|
400
555
|
}
|
|
401
556
|
export function isZodOptional(schema) {
|
|
402
557
|
return hasZodType(schema, 'optional');
|
|
@@ -571,6 +726,11 @@ export function getSchemaType(schema) {
|
|
|
571
726
|
if (isPhoneSchema(schema)) {
|
|
572
727
|
return FieldSchemaType.PHONE;
|
|
573
728
|
}
|
|
729
|
+
// Check for address schemas (created by z_address()) — BEFORE the generic object case, which an
|
|
730
|
+
// address would otherwise fall into as a plain OBJECT with no field of its own (#561).
|
|
731
|
+
if (isAddressSchema(schema)) {
|
|
732
|
+
return FieldSchemaType.ADDRESS;
|
|
733
|
+
}
|
|
574
734
|
// Check for versioned foreign key schemas (created by z_foreignKeyVersioned())
|
|
575
735
|
if (isVersionedForeignKeySchema(schema)) {
|
|
576
736
|
return FieldSchemaType.VERSIONED_FOREIGN_KEY;
|
|
@@ -618,7 +778,26 @@ export function getSchemaType(schema) {
|
|
|
618
778
|
return FieldSchemaType.UNKNOWN;
|
|
619
779
|
}
|
|
620
780
|
}
|
|
621
|
-
|
|
781
|
+
/*
|
|
782
|
+
* A `z.date()` is a DAY unless it declares itself a MOMENT.
|
|
783
|
+
*
|
|
784
|
+
* In Wildo a datetime is Date-based — an ISO-8601 string is a wire format for one, never the
|
|
785
|
+
* model — so `ZodDate` is the carrier for both "a day" (`dueDate`) and "a point in time"
|
|
786
|
+
* (`remindAt`). Nothing in the schema shape distinguishes them, which left `DATETIME`
|
|
787
|
+
* effectively unreachable: every date classified as `DATE`, so every one of them EDITED through
|
|
788
|
+
* the day picker and DISPLAYED through the day formatter, and a moment silently lost its hour
|
|
789
|
+
* on both surfaces while `FormFieldDateTime` and `DisplayFieldDateTime` sat unreachable.
|
|
790
|
+
*
|
|
791
|
+
* The declaration is what distinguishes them, and it already exists: `dateTimeUI()` is applyable
|
|
792
|
+
* to `ZodDate` precisely to say "this value carries a time of day". Reading it here means the
|
|
793
|
+
* two renderer dispatchers — which both key on this classification alone — pick the datetime
|
|
794
|
+
* pair without either of them learning a new rule.
|
|
795
|
+
*
|
|
796
|
+
* Scoped to the RENDERING classification on purpose: nothing in DTO building or persistence
|
|
797
|
+
* branches on `DATE` vs `DATETIME` (checked), so this changes which control a field gets and
|
|
798
|
+
* nothing about how its value is carried or stored.
|
|
799
|
+
*/
|
|
800
|
+
case 'date': return getDateTimeUI(unwrapped) !== undefined ? FieldSchemaType.DATETIME : FieldSchemaType.DATE;
|
|
622
801
|
case 'object': return FieldSchemaType.OBJECT;
|
|
623
802
|
case 'array': return FieldSchemaType.ARRAY;
|
|
624
803
|
case 'set': return FieldSchemaType.SET;
|
|
@@ -652,6 +831,210 @@ export function getSchemaType(schema) {
|
|
|
652
831
|
default: return FieldSchemaType.UNKNOWN;
|
|
653
832
|
}
|
|
654
833
|
}
|
|
834
|
+
/**
|
|
835
|
+
* Which classified schema types HOLD FIELDS — parts that are fields of the resource in their own right, each
|
|
836
|
+
* labelled, displayed and validated under its own dotted path (`privacy.consentedPurposes`) — as opposed to
|
|
837
|
+
* being ONE value whose parts belong to the value.
|
|
838
|
+
*
|
|
839
|
+
* A structured value is a Zod object too: `z_money()` is `{ amount, currency, decimals }`, `z_address()` is
|
|
840
|
+
* `{ line1, …, coordinates }`, every `z_range()` is `{ from, to }`. But its parts are not fields. The value's
|
|
841
|
+
* own component draws and names them (`LOW_LEVEL_FORM_FIELD_ADDRESS` carries `line1`, the money field reads the
|
|
842
|
+
* currency through `system_enums`), so the resource has exactly ONE word for the whole value — its field label —
|
|
843
|
+
* and no word for any part. Walking into a structured value as if it were a nested object is how the application
|
|
844
|
+
* labelling planner came to generate, translate and ship `fields.amount.amount`, `fields.amount.currency` and
|
|
845
|
+
* `fields.amount.decimals` for a money field no screen ever looked up (#1959).
|
|
846
|
+
*
|
|
847
|
+
* A `Record` over the whole enum rather than a list of either half, so a classification added tomorrow is a
|
|
848
|
+
* compile error here until someone decides which it is — the label walk, the labelling planner, the frontend
|
|
849
|
+
* label introspection and the validation summary all read this one answer through {@link isFieldContainerSchema}.
|
|
850
|
+
*
|
|
851
|
+
* `VERSIONED_OBJECT` holds fields: `versioned()` returns the application's own object with two hidden bookkeeping
|
|
852
|
+
* fields added, and the display renders it through the plain object branch, child field by child field.
|
|
853
|
+
*/
|
|
854
|
+
const FIELD_SCHEMA_TYPE_HOLDS_FIELDS = {
|
|
855
|
+
[FieldSchemaType.OBJECT]: true,
|
|
856
|
+
[FieldSchemaType.VERSIONED_OBJECT]: true,
|
|
857
|
+
[FieldSchemaType.STRING]: false,
|
|
858
|
+
[FieldSchemaType.NUMBER]: false,
|
|
859
|
+
[FieldSchemaType.BIGINT]: false,
|
|
860
|
+
[FieldSchemaType.BOOLEAN]: false,
|
|
861
|
+
[FieldSchemaType.ENUM]: false,
|
|
862
|
+
[FieldSchemaType.DATE]: false,
|
|
863
|
+
/*
|
|
864
|
+
* An ARRAY is not a container of FIELDS: its items are rows of one schema, and a walk that labels by field
|
|
865
|
+
* steps through it to the item (index-free) and asks this question of the ITEM. SET, TUPLE, RECORD and MAP
|
|
866
|
+
* are never walked for field labels at all.
|
|
867
|
+
*/
|
|
868
|
+
[FieldSchemaType.ARRAY]: false,
|
|
869
|
+
[FieldSchemaType.SET]: false,
|
|
870
|
+
[FieldSchemaType.TUPLE]: false,
|
|
871
|
+
[FieldSchemaType.RECORD]: false,
|
|
872
|
+
[FieldSchemaType.MAP]: false,
|
|
873
|
+
[FieldSchemaType.TIME]: false,
|
|
874
|
+
[FieldSchemaType.DATETIME]: false,
|
|
875
|
+
[FieldSchemaType.RANGE_NUMBER]: false,
|
|
876
|
+
[FieldSchemaType.RANGE_DATE]: false,
|
|
877
|
+
[FieldSchemaType.RANGE_DATETIME]: false,
|
|
878
|
+
[FieldSchemaType.RANGE_TIME]: false,
|
|
879
|
+
[FieldSchemaType.RANGE_BIGDECIMAL]: false,
|
|
880
|
+
[FieldSchemaType.RANGE_MONEY]: false,
|
|
881
|
+
[FieldSchemaType.MONEY]: false,
|
|
882
|
+
[FieldSchemaType.BIGDECIMAL]: false,
|
|
883
|
+
[FieldSchemaType.CODE]: false,
|
|
884
|
+
[FieldSchemaType.FILE]: false,
|
|
885
|
+
[FieldSchemaType.DURATION]: false,
|
|
886
|
+
[FieldSchemaType.PHONE]: false,
|
|
887
|
+
[FieldSchemaType.ADDRESS]: false,
|
|
888
|
+
[FieldSchemaType.VERSIONED_FOREIGN_KEY]: false,
|
|
889
|
+
[FieldSchemaType.UNKNOWN]: false,
|
|
890
|
+
};
|
|
891
|
+
/**
|
|
892
|
+
* Does this schema HOLD FIELDS of the resource — so a walk that labels by field path descends into it — rather
|
|
893
|
+
* than being a scalar or a structured value whose parts are its own (money, address, duration, code, file, every
|
|
894
|
+
* range)? See {@link FIELD_SCHEMA_TYPE_HOLDS_FIELDS} for why the two are different and why the answer is read off
|
|
895
|
+
* {@link getSchemaType} rather than off a list of value types.
|
|
896
|
+
*
|
|
897
|
+
* Takes the schema as declared, wrappers included: `getSchemaType` recognises every structured value through its
|
|
898
|
+
* wrappers, so `z_money().optional()` answers `false` like `z_money()` does.
|
|
899
|
+
*
|
|
900
|
+
* @example
|
|
901
|
+
* isFieldContainerSchema(z.object({ city: z.string() })); // true — `address.city` is a field
|
|
902
|
+
* isFieldContainerSchema(z_address()); // false — `line1` is the address's, not the resource's
|
|
903
|
+
*/
|
|
904
|
+
export function isFieldContainerSchema(schema) {
|
|
905
|
+
return FIELD_SCHEMA_TYPE_HOLDS_FIELDS[getSchemaType(schema)];
|
|
906
|
+
}
|
|
907
|
+
// =============================================================================
|
|
908
|
+
// VALUE TYPE INFERENCE — the runtime twin of getSchemaType
|
|
909
|
+
// =============================================================================
|
|
910
|
+
/**
|
|
911
|
+
* An ISO-8601 DATETIME: a calendar date, a `T` (or the JSON-ish space form), a clock time, and an
|
|
912
|
+
* optional zone designator.
|
|
913
|
+
*
|
|
914
|
+
* The `T` is what makes this safe to recognise. No identifier, version, code or reference in this
|
|
915
|
+
* codebase's data has that shape, so a string matching it is a datetime with as much certainty as
|
|
916
|
+
* a declared schema would have given.
|
|
917
|
+
*/
|
|
918
|
+
const ISO_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
919
|
+
/** An ISO-8601 CALENDAR DATE and nothing else. */
|
|
920
|
+
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
921
|
+
/**
|
|
922
|
+
* Does this `YYYY-MM-DD` name a real day?
|
|
923
|
+
*
|
|
924
|
+
* `new Date('2026-02-30')` does not throw — it rolls over to 2 March — so a pattern match alone
|
|
925
|
+
* would accept a string that is shaped like a date and is not one. Round-tripping the parsed parts
|
|
926
|
+
* back out is what rejects it, and it is the difference between recognising dates and recognising
|
|
927
|
+
* things with four digits, a dash and some numbers.
|
|
928
|
+
*/
|
|
929
|
+
function namesARealCalendarDay(year, month, day) {
|
|
930
|
+
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
931
|
+
return parsed.getUTCFullYear() === year
|
|
932
|
+
&& parsed.getUTCMonth() === month - 1
|
|
933
|
+
&& parsed.getUTCDate() === day;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* WHAT IS THIS VALUE, when no schema can say?
|
|
937
|
+
*
|
|
938
|
+
* The runtime twin of {@link getSchemaType}, and it exists for exactly one caller shape: a field
|
|
939
|
+
* declared `z.unknown()`. That is a DELIBERATE posture — an outbound webhook's `payload`, an
|
|
940
|
+
* integration's passthrough blob, a stored provider response — and the framework's answer used to
|
|
941
|
+
* be a single one: pretty-print everything as JSON into the code viewer. Faithful, and wrong for
|
|
942
|
+
* the common case, because most values inside such a payload are ordinary scalars. A name rendered
|
|
943
|
+
* as `"Ada"` in a monospace code block, a flag as `true`, a timestamp as a quoted ISO string: each
|
|
944
|
+
* is the value, and none of them is how that value reads anywhere else in the application.
|
|
945
|
+
*
|
|
946
|
+
* So when the schema cannot say, ask the value — and route a recognisable primitive to the
|
|
947
|
+
* formatter that primitive would have had if it HAD been declared.
|
|
948
|
+
*
|
|
949
|
+
* ## `undefined` is an answer, and the important one
|
|
950
|
+
*
|
|
951
|
+
* An object, an array, `null`, a function, a symbol — anything this cannot name — returns
|
|
952
|
+
* `undefined`, and the caller keeps the JSON rendering. That is the case the mechanism was built
|
|
953
|
+
* for and it is not being taken away; what changes is that scalars stop going through it.
|
|
954
|
+
*
|
|
955
|
+
* ## What is deliberately NOT inferred
|
|
956
|
+
*
|
|
957
|
+
* - **A number is never a date.** `1789903936618` is a plausible epoch and also a plausible id,
|
|
958
|
+
* counter, amount or checksum, and there is nothing in the value to tell them apart. Guessing
|
|
959
|
+
* would silently reformat every large integer in every opaque payload as a date in 2026.
|
|
960
|
+
* - **A numeric STRING stays a string.** `'42'` in a passthrough payload was sent as text by
|
|
961
|
+
* whoever sent it; rendering it through the number formatter would apply grouping and locale
|
|
962
|
+
* decimals to something that may be a code, a zip or a version segment.
|
|
963
|
+
* - **`true`/`false` as strings stay strings**, for the same reason.
|
|
964
|
+
* - **A bare `YYYY-MM-DD` IS taken as a date**, which is the one judgement call here. It is the
|
|
965
|
+
* only shape in the list that something else could plausibly be, and the calendar round-trip
|
|
966
|
+
* above is what keeps it from swallowing near-misses. In payload data this shape is a date
|
|
967
|
+
* essentially always, and the cost of being wrong is a differently-formatted string rather than
|
|
968
|
+
* a lost value.
|
|
969
|
+
*
|
|
970
|
+
* ## A string that CARRIES an object or array is that object, not a string
|
|
971
|
+
*
|
|
972
|
+
* The webhook `payload` named first above reaches the display SERIALIZED — a string whose content
|
|
973
|
+
* is a JSON object — and `typeof` alone reads it as a string. The first version of this function
|
|
974
|
+
* did exactly that, and the one case the mechanism promised to keep was the one it took away: the
|
|
975
|
+
* delivery-log payload rendered as a single raw line in a paragraph, found by
|
|
976
|
+
* `browser-settings-hub-webhooks` step 6 on its first run after this landed (#1411).
|
|
977
|
+
*
|
|
978
|
+
* So a string whose trimmed text opens with `{` or `[` AND parses to an object or array answers
|
|
979
|
+
* `undefined`, and keeps the JSON rendering — which already unwraps a string-wrapped value
|
|
980
|
+
* (`DisplayFieldJson`). The opening-character check keeps the parse off ordinary text, and the parse
|
|
981
|
+
* keeps prose that merely starts with a bracket ("[draft] …") a string.
|
|
982
|
+
*
|
|
983
|
+
* ## An author who disagrees has the existing escape hatch
|
|
984
|
+
*
|
|
985
|
+
* `displayComponent` is resolved BEFORE this is consulted, so a field that must always render as
|
|
986
|
+
* JSON says so and never reaches the inference.
|
|
987
|
+
*/
|
|
988
|
+
/**
|
|
989
|
+
* Does this string SERIALIZE an object or array? See "A string that CARRIES an object or array"
|
|
990
|
+
* on {@link inferFieldSchemaTypeFromValue}. A JSON scalar (`"42"`, `"true"`) deliberately does not
|
|
991
|
+
* count — those are the numeric and boolean strings that function keeps as strings on purpose.
|
|
992
|
+
*/
|
|
993
|
+
function carriesJsonStructure(text) {
|
|
994
|
+
const trimmed = text.trim();
|
|
995
|
+
const opener = trimmed.charAt(0);
|
|
996
|
+
if (opener !== '{' && opener !== '[')
|
|
997
|
+
return false;
|
|
998
|
+
try {
|
|
999
|
+
const parsed = JSON.parse(trimmed);
|
|
1000
|
+
return parsed !== null && typeof parsed === 'object';
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
export function inferFieldSchemaTypeFromValue(value) {
|
|
1007
|
+
if (value instanceof Date) {
|
|
1008
|
+
return FieldSchemaType.DATETIME;
|
|
1009
|
+
}
|
|
1010
|
+
switch (typeof value) {
|
|
1011
|
+
case 'boolean':
|
|
1012
|
+
return FieldSchemaType.BOOLEAN;
|
|
1013
|
+
case 'bigint':
|
|
1014
|
+
return FieldSchemaType.BIGINT;
|
|
1015
|
+
case 'number':
|
|
1016
|
+
// Including NaN and the infinities, deliberately: `JSON.stringify(NaN)` is the string
|
|
1017
|
+
// `null`, so the JSON path would report a value that is not there. The number formatter
|
|
1018
|
+
// renders them as themselves.
|
|
1019
|
+
return FieldSchemaType.NUMBER;
|
|
1020
|
+
case 'string':
|
|
1021
|
+
break;
|
|
1022
|
+
default:
|
|
1023
|
+
return undefined;
|
|
1024
|
+
}
|
|
1025
|
+
const text = value;
|
|
1026
|
+
if (carriesJsonStructure(text)) {
|
|
1027
|
+
return undefined;
|
|
1028
|
+
}
|
|
1029
|
+
if (ISO_DATETIME_PATTERN.test(text) && !Number.isNaN(Date.parse(text))) {
|
|
1030
|
+
return FieldSchemaType.DATETIME;
|
|
1031
|
+
}
|
|
1032
|
+
const dateParts = ISO_DATE_PATTERN.exec(text);
|
|
1033
|
+
if (dateParts && namesARealCalendarDay(Number(dateParts[1]), Number(dateParts[2]), Number(dateParts[3]))) {
|
|
1034
|
+
return FieldSchemaType.DATE;
|
|
1035
|
+
}
|
|
1036
|
+
return FieldSchemaType.STRING;
|
|
1037
|
+
}
|
|
655
1038
|
// =============================================================================
|
|
656
1039
|
// ZOD HELPER GETTER
|
|
657
1040
|
// =============================================================================
|
|
@@ -720,6 +1103,100 @@ export function getMergedObjectOrUnionShape(schema) {
|
|
|
720
1103
|
}
|
|
721
1104
|
return {};
|
|
722
1105
|
}
|
|
1106
|
+
/**
|
|
1107
|
+
* The key a union of OBJECTS is discriminated by — the declared one, or the one a plain `z.union` carries
|
|
1108
|
+
* structurally. `undefined` when the schema is not a union, or is a plain union no key discriminates.
|
|
1109
|
+
*
|
|
1110
|
+
* ## Why a plain union needs an answer
|
|
1111
|
+
*
|
|
1112
|
+
* `z.discriminatedUnion(key, …)` records its key; `z.union([V1, V2])` does not, even when every member
|
|
1113
|
+
* declares the same key as a literal and those literals never collide — which is exactly how a versioned
|
|
1114
|
+
* record is written (`schemaVersion: z.literal(1)` / `z.literal(2)`). Such a union IS discriminated; only
|
|
1115
|
+
* the spelling differs. Measured 2026-09-14 on the compliance programme's `documentationRequirements`
|
|
1116
|
+
* items (`z.union([V1, V2])`): the display resolved no member, so every item rendered an em-dash, and the
|
|
1117
|
+
* label walk collected no field inside them, so `governanceScope.subjectKind` could not be worded
|
|
1118
|
+
* anywhere (#1022).
|
|
1119
|
+
*
|
|
1120
|
+
* The real instance is one step less regular than that: V1 predates `schemaVersion` and does not declare it
|
|
1121
|
+
* at all, while V2 declares `z.literal(2)`. Both are `z.strictObject`s, so V1 REJECTS any value carrying the
|
|
1122
|
+
* key — its absence is as decisive as a literal.
|
|
1123
|
+
*
|
|
1124
|
+
* ## The inference, and why it cannot guess
|
|
1125
|
+
*
|
|
1126
|
+
* A key discriminates a plain union only when EVERY option is an object and, for that key:
|
|
1127
|
+
*
|
|
1128
|
+
* 1. every member either declares it as a bare `z.literal`, or omits it AND is strict (`z.strictObject`,
|
|
1129
|
+
* whose catchall is `never`) — a non-strict member that omits the key would also accept a value carrying it;
|
|
1130
|
+
* 2. the declared literal values are pairwise DISJOINT across members;
|
|
1131
|
+
* 3. at least one member declares it, and at most ONE member omits it.
|
|
1132
|
+
*
|
|
1133
|
+
* Under those conditions a value selects at most one member — by its literal, or by carrying no value for the
|
|
1134
|
+
* key (the one member that omits it) — so resolving by it is exact, the same guarantee
|
|
1135
|
+
* `z.discriminatedUnion` enforces at construction. Keys are tried in authoring order across the members, so
|
|
1136
|
+
* the answer is deterministic. A union that fails every key answers `undefined`, and callers keep treating
|
|
1137
|
+
* it as undiscriminated.
|
|
1138
|
+
*
|
|
1139
|
+
* One function, so the label walk (`collectResourceLabelFieldEntries`), the application label deciders and
|
|
1140
|
+
* the display ({@link resolveDiscriminatedUnionVariant}) agree about which unions have members at all.
|
|
1141
|
+
*/
|
|
1142
|
+
export function getUnionDiscriminatorKey(schema) {
|
|
1143
|
+
if (!isZodUnion(schema))
|
|
1144
|
+
return undefined;
|
|
1145
|
+
const declared = getZodDefStringProperty(schema, 'discriminator');
|
|
1146
|
+
if (declared)
|
|
1147
|
+
return declared;
|
|
1148
|
+
const options = (getZodDefArrayProperty(schema, 'options') ?? []);
|
|
1149
|
+
if (options.length < 2)
|
|
1150
|
+
return undefined;
|
|
1151
|
+
if (!options.every((option) => isZodTypeAny(option) && isZodObject(option)))
|
|
1152
|
+
return undefined;
|
|
1153
|
+
const members = options;
|
|
1154
|
+
const memberShapes = members.map((member) => getZodObjectShape(member));
|
|
1155
|
+
const candidateKeys = [...new Set(memberShapes.flatMap((shape) => Object.keys(shape)))];
|
|
1156
|
+
for (const key of candidateKeys) {
|
|
1157
|
+
const seenValues = new Set();
|
|
1158
|
+
let declaringMembers = 0;
|
|
1159
|
+
let omittingMembers = 0;
|
|
1160
|
+
const discriminates = memberShapes.every((shape, index) => {
|
|
1161
|
+
const keySchema = shape[key];
|
|
1162
|
+
if (!keySchema) {
|
|
1163
|
+
omittingMembers += 1;
|
|
1164
|
+
return omittingMembers <= 1 && isStrictZodObject(members[index]);
|
|
1165
|
+
}
|
|
1166
|
+
if (!isZodLiteral(keySchema))
|
|
1167
|
+
return false;
|
|
1168
|
+
const literalValues = getZodDefArrayProperty(keySchema, 'values') ?? [];
|
|
1169
|
+
if (literalValues.length === 0 || literalValues.some((literalValue) => seenValues.has(literalValue)))
|
|
1170
|
+
return false;
|
|
1171
|
+
for (const literalValue of literalValues)
|
|
1172
|
+
seenValues.add(literalValue);
|
|
1173
|
+
declaringMembers += 1;
|
|
1174
|
+
return true;
|
|
1175
|
+
});
|
|
1176
|
+
if (discriminates && declaringMembers > 0)
|
|
1177
|
+
return key;
|
|
1178
|
+
}
|
|
1179
|
+
return undefined;
|
|
1180
|
+
}
|
|
1181
|
+
/** A `z.strictObject` — its catchall is `never`, so it rejects every key its shape does not declare. */
|
|
1182
|
+
function isStrictZodObject(schema) {
|
|
1183
|
+
const catchall = getZodDefSchemaProperty(schema, 'catchall');
|
|
1184
|
+
return catchall !== undefined && getZodType(catchall) === 'never';
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* The OBJECT members of a union that {@link getUnionDiscriminatorKey} discriminates — declared or plain —
|
|
1188
|
+
* in authoring order; `[]` for anything else. Pass the UNWRAPPED schema.
|
|
1189
|
+
*
|
|
1190
|
+
* For a declared discriminated union the non-object options are filtered out, as
|
|
1191
|
+
* `AutomatedLayoutFrontendUtils.getDiscriminatedUnionObjectOptions` does; a plain union has none (the
|
|
1192
|
+
* inference requires every option to be an object).
|
|
1193
|
+
*/
|
|
1194
|
+
export function getUnionObjectMembers(schema) {
|
|
1195
|
+
if (!getUnionDiscriminatorKey(schema))
|
|
1196
|
+
return [];
|
|
1197
|
+
const options = (getZodDefArrayProperty(schema, 'options') ?? []);
|
|
1198
|
+
return options.filter((option) => isZodTypeAny(option) && isZodObject(option));
|
|
1199
|
+
}
|
|
723
1200
|
/**
|
|
724
1201
|
* Resolve the ONE variant of a discriminated union that a given value belongs to.
|
|
725
1202
|
*
|
|
@@ -729,22 +1206,28 @@ export function getMergedObjectOrUnionShape(schema) {
|
|
|
729
1206
|
* which variant this is, so the shape can be exactly that variant's, with no fields belonging to
|
|
730
1207
|
* the alternatives the value did not choose.
|
|
731
1208
|
*
|
|
1209
|
+
* The discriminator is {@link getUnionDiscriminatorKey}'s: declared, or carried structurally by a plain
|
|
1210
|
+
* `z.union` (#1022 — a versioned record's items). For such a plain union a value carrying NO discriminator
|
|
1211
|
+
* belongs to the one strict member that omits the key, when there is one; for a declared discriminated union
|
|
1212
|
+
* it belongs to none.
|
|
1213
|
+
*
|
|
732
1214
|
* Returns `null` when the schema is not a discriminated union, when the value carries no
|
|
733
|
-
* discriminator, or when no variant matches it — all three are "this caller cannot
|
|
734
|
-
* caller that renders should say nothing rather than guess a variant.
|
|
1215
|
+
* discriminator its union can place, or when no variant matches it — all three are "this caller cannot
|
|
1216
|
+
* narrow", and a caller that renders should say nothing rather than guess a variant.
|
|
735
1217
|
*/
|
|
736
1218
|
export function resolveDiscriminatedUnionVariant(schema, value) {
|
|
737
|
-
if (!isDiscriminatedUnion(schema))
|
|
738
|
-
return null;
|
|
739
1219
|
if (typeof value !== 'object' || value === null)
|
|
740
1220
|
return null;
|
|
741
|
-
const discriminator =
|
|
1221
|
+
const discriminator = getUnionDiscriminatorKey(schema);
|
|
742
1222
|
if (!discriminator)
|
|
743
1223
|
return null;
|
|
744
1224
|
const discriminatorValue = value[discriminator];
|
|
745
|
-
if (discriminatorValue === undefined)
|
|
746
|
-
return null;
|
|
747
1225
|
const options = (getZodDefArrayProperty(schema, 'options') ?? []);
|
|
1226
|
+
if (discriminatorValue === undefined) {
|
|
1227
|
+
if (getZodDefStringProperty(schema, 'discriminator'))
|
|
1228
|
+
return null;
|
|
1229
|
+
return options.find((option) => isZodObject(option) && getZodObjectShape(option)[discriminator] === undefined) ?? null;
|
|
1230
|
+
}
|
|
748
1231
|
for (const option of options) {
|
|
749
1232
|
if (!isZodObject(option))
|
|
750
1233
|
continue;
|
|
@@ -786,6 +1269,33 @@ export function getZodDefArrayProperty(schema, property) {
|
|
|
786
1269
|
const value = def?.[property];
|
|
787
1270
|
return isArray(value) ? value : undefined;
|
|
788
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* The payload of one entry of a schema's `checks` array.
|
|
1274
|
+
*
|
|
1275
|
+
* A CHECK IS ITSELF A ZOD INTERNAL, which is the whole reason this helper exists. Its own fields
|
|
1276
|
+
* live at `check._zod.def`, not on the check object, and they are keyed by a `check` discriminator
|
|
1277
|
+
* (`min_length`, `greater_than`, `string_format`, …) rather than by the Zod 3 `kind` property.
|
|
1278
|
+
*
|
|
1279
|
+
* Three readers in this file — {@link getStringFormat}, {@link getStringConstraints} and
|
|
1280
|
+
* {@link getNumberConstraints} — walked `checks` and read `check.kind`, which is `undefined` for
|
|
1281
|
+
* every check Zod 4 produces. None of them threw; each returned an empty answer, so a string with
|
|
1282
|
+
* `.max(120)` reported no maximum, a number with `.min(1)` reported no minimum, and `z.email()`
|
|
1283
|
+
* reported no format. Every consumer degraded silently: an `<input>` with no `maxLength`, no `min`,
|
|
1284
|
+
* and `type="text"` where `type="email"` was intended.
|
|
1285
|
+
*/
|
|
1286
|
+
function getZodCheckDefinition(check) {
|
|
1287
|
+
if (typeof check !== 'object' || check === null)
|
|
1288
|
+
return undefined;
|
|
1289
|
+
const internals = check._zod;
|
|
1290
|
+
if (typeof internals === 'object' && internals !== null && 'def' in internals) {
|
|
1291
|
+
const definition = internals.def;
|
|
1292
|
+
if (typeof definition === 'object' && definition !== null)
|
|
1293
|
+
return definition;
|
|
1294
|
+
}
|
|
1295
|
+
// A check authored as a plain object (or a future Zod shape that flattens the payload) is read
|
|
1296
|
+
// directly rather than skipped — the discriminator lookup below tolerates a miss.
|
|
1297
|
+
return check;
|
|
1298
|
+
}
|
|
789
1299
|
export function getZodDefStringProperty(schema, property) {
|
|
790
1300
|
const def = getZodDef(schema);
|
|
791
1301
|
const value = def?.[property];
|
|
@@ -955,34 +1465,82 @@ export function getShowWhenParams(schema) {
|
|
|
955
1465
|
const metadata = getCustomFieldMetadata(schema);
|
|
956
1466
|
return metadata[ZodDecorator.SHOW_WHEN];
|
|
957
1467
|
}
|
|
1468
|
+
// =============================================================================
|
|
1469
|
+
// Enhanced Primitive Helpers
|
|
1470
|
+
// =============================================================================
|
|
1471
|
+
// ------------------------------------------------------------
|
|
1472
|
+
// STRING SCHEMA UTILITIES
|
|
1473
|
+
// ------------------------------------------------------------
|
|
1474
|
+
/**
|
|
1475
|
+
* Keep the TIGHTER of two bounds a schema declares, so several constraints compose the way the
|
|
1476
|
+
* validator composes them: every lower bound must be cleared, so the highest governs, and every
|
|
1477
|
+
* upper bound must be respected, so the lowest does.
|
|
1478
|
+
*
|
|
1479
|
+
* `undefined` means "nothing declared yet" and always loses, which is what makes these safe to fold
|
|
1480
|
+
* over a checks array in any order.
|
|
1481
|
+
*/
|
|
1482
|
+
function tightestLowerBound(current, candidate) {
|
|
1483
|
+
return current === undefined ? candidate : Math.max(current, candidate);
|
|
1484
|
+
}
|
|
1485
|
+
function tightestUpperBound(current, candidate) {
|
|
1486
|
+
return current === undefined ? candidate : Math.min(current, candidate);
|
|
1487
|
+
}
|
|
958
1488
|
/**
|
|
959
|
-
*
|
|
1489
|
+
* The format a string schema declares — `email`, `url`, `datetime`, … — or `null` for a plain one.
|
|
1490
|
+
*
|
|
1491
|
+
* Zod 4 expresses a format TWO ways and both are read here, because an author picks between them
|
|
1492
|
+
* freely and the rendered field must not depend on which they chose:
|
|
1493
|
+
*
|
|
1494
|
+
* - a dedicated schema type (`z.email()`, `z.iso.datetime()`) carries `def.format` and no checks;
|
|
1495
|
+
* - a chained refinement (`z.string().email()`) carries a `string_format` CHECK whose own
|
|
1496
|
+
* definition names the format.
|
|
1497
|
+
*
|
|
1498
|
+
* `ipv4` and `ipv6` both answer `'ip'`: this vocabulary exists to choose an input type and a
|
|
1499
|
+
* display treatment, and those are the same for either family.
|
|
960
1500
|
*/
|
|
961
1501
|
export function getStringFormat(schema) {
|
|
962
1502
|
const unwrapped = unwrapSchema(schema);
|
|
963
1503
|
if (getZodType(unwrapped) !== 'string') {
|
|
964
1504
|
return null;
|
|
965
1505
|
}
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
1506
|
+
const declared = getZodDefStringProperty(unwrapped, 'format');
|
|
1507
|
+
const fromDeclaration = normalizeStringFormat(declared);
|
|
1508
|
+
if (fromDeclaration)
|
|
1509
|
+
return fromDeclaration;
|
|
1510
|
+
for (const check of getZodDefArrayProperty(unwrapped, 'checks') ?? []) {
|
|
1511
|
+
const definition = getZodCheckDefinition(check);
|
|
1512
|
+
if (definition?.check !== 'string_format')
|
|
969
1513
|
continue;
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
case 'url': return 'url';
|
|
974
|
-
case 'uuid': return 'uuid';
|
|
975
|
-
case 'cuid': return 'cuid';
|
|
976
|
-
case 'cuid2': return 'cuid2';
|
|
977
|
-
case 'ulid': return 'ulid';
|
|
978
|
-
case 'regex': return 'regex';
|
|
979
|
-
case 'datetime': return 'datetime';
|
|
980
|
-
case 'time': return 'time';
|
|
981
|
-
case 'ip': return 'ip';
|
|
982
|
-
}
|
|
1514
|
+
const fromCheck = normalizeStringFormat(definition.format);
|
|
1515
|
+
if (fromCheck)
|
|
1516
|
+
return fromCheck;
|
|
983
1517
|
}
|
|
984
1518
|
return null;
|
|
985
1519
|
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Zod's format name → this vocabulary. Unknown names answer `null` rather than being passed
|
|
1522
|
+
* through, because every consumer switches on {@link StringFormat} and an unrecognised value would
|
|
1523
|
+
* fall to a default anyway — but as a widened type, which is how a format nobody handles ends up
|
|
1524
|
+
* silently typed as one that is handled.
|
|
1525
|
+
*/
|
|
1526
|
+
function normalizeStringFormat(value) {
|
|
1527
|
+
switch (value) {
|
|
1528
|
+
case 'email': return 'email';
|
|
1529
|
+
case 'url': return 'url';
|
|
1530
|
+
case 'uuid': return 'uuid';
|
|
1531
|
+
case 'cuid': return 'cuid';
|
|
1532
|
+
case 'cuid2': return 'cuid2';
|
|
1533
|
+
case 'ulid': return 'ulid';
|
|
1534
|
+
case 'regex': return 'regex';
|
|
1535
|
+
case 'datetime': return 'datetime';
|
|
1536
|
+
case 'time': return 'time';
|
|
1537
|
+
// Both IP families answer the same thing — see the note on `getStringFormat`.
|
|
1538
|
+
case 'ip':
|
|
1539
|
+
case 'ipv4':
|
|
1540
|
+
case 'ipv6': return 'ip';
|
|
1541
|
+
default: return null;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
986
1544
|
/**
|
|
987
1545
|
* Get min/max length constraints from string schema
|
|
988
1546
|
*/
|
|
@@ -991,24 +1549,32 @@ export function getStringConstraints(schema) {
|
|
|
991
1549
|
if (getZodType(unwrapped) !== 'string') {
|
|
992
1550
|
return {};
|
|
993
1551
|
}
|
|
994
|
-
const checks = getZodDefArrayProperty(unwrapped, 'checks') ?? [];
|
|
995
1552
|
const result = {};
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1553
|
+
// Zod 4 keys these by a `check` discriminator and names the bound after the constraint
|
|
1554
|
+
// (`minimum` / `maximum` / `length`), where Zod 3 used `kind` plus a shared `value`.
|
|
1555
|
+
//
|
|
1556
|
+
// Bounds ACCUMULATE toward the tightest rather than being assigned, because a schema may carry
|
|
1557
|
+
// several and the last one authored is not the one that governs. `z.string().min(3).nonempty()`
|
|
1558
|
+
// is ordinary authoring — a business floor plus a sanity floor — and assigning would report
|
|
1559
|
+
// `minLength: 1` for a schema that rejects `"ab"`. The same reasoning the truncation and the
|
|
1560
|
+
// exclusive-bound branches already follow: a hint looser than the schema invites a value the
|
|
1561
|
+
// server refuses, which is worse than no hint at all.
|
|
1562
|
+
for (const check of getZodDefArrayProperty(unwrapped, 'checks') ?? []) {
|
|
1563
|
+
const definition = getZodCheckDefinition(check);
|
|
1564
|
+
if (!definition)
|
|
1002
1565
|
continue;
|
|
1003
|
-
|
|
1004
|
-
|
|
1566
|
+
const minimum = definition.minimum;
|
|
1567
|
+
const maximum = definition.maximum;
|
|
1568
|
+
const exactLength = definition.length;
|
|
1569
|
+
if (definition.check === 'min_length' && typeof minimum === 'number') {
|
|
1570
|
+
result.minLength = tightestLowerBound(result.minLength, minimum);
|
|
1005
1571
|
}
|
|
1006
|
-
if (
|
|
1007
|
-
result.maxLength =
|
|
1572
|
+
if (definition.check === 'max_length' && typeof maximum === 'number') {
|
|
1573
|
+
result.maxLength = tightestUpperBound(result.maxLength, maximum);
|
|
1008
1574
|
}
|
|
1009
|
-
if (
|
|
1010
|
-
result.minLength =
|
|
1011
|
-
result.maxLength =
|
|
1575
|
+
if (definition.check === 'length_equals' && typeof exactLength === 'number') {
|
|
1576
|
+
result.minLength = tightestLowerBound(result.minLength, exactLength);
|
|
1577
|
+
result.maxLength = tightestUpperBound(result.maxLength, exactLength);
|
|
1012
1578
|
}
|
|
1013
1579
|
}
|
|
1014
1580
|
return result;
|
|
@@ -1025,29 +1591,66 @@ export function getNumberConstraints(schema) {
|
|
|
1025
1591
|
if (type !== 'number' && type !== 'int') {
|
|
1026
1592
|
return {};
|
|
1027
1593
|
}
|
|
1028
|
-
const checks = getZodDefArrayProperty(unwrapped, 'checks') ?? [];
|
|
1029
1594
|
const result = {};
|
|
1030
|
-
// int type in Zod v4
|
|
1031
1595
|
if (type === 'int') {
|
|
1032
1596
|
result.isInt = true;
|
|
1033
1597
|
result.step = 1;
|
|
1034
1598
|
}
|
|
1599
|
+
// Zod 4 names a bound by DIRECTION rather than by role — `greater_than` / `less_than`, each
|
|
1600
|
+
// carrying an `inclusive` flag — where Zod 3 had `min` / `max`. Integer-ness is a `number_format`
|
|
1601
|
+
// check (`z.number().int()`) or the schema type itself (`z.int()`), never a `kind: 'int'`.
|
|
1602
|
+
//
|
|
1603
|
+
// Read INTEGER-NESS FIRST, in its own pass, because an exclusive bound cannot be translated
|
|
1604
|
+
// without knowing it — see below.
|
|
1605
|
+
//
|
|
1606
|
+
// It arrives the same TWO ways a string format does, and both must be read: `z.number().int()`
|
|
1607
|
+
// adds a `number_format` CHECK, while `z.int()` is its own schema type carrying `def.format` and
|
|
1608
|
+
// no checks at all. Reading only the check left `z.int()` — the shorter and more idiomatic
|
|
1609
|
+
// spelling — reporting no integer constraint and therefore no step.
|
|
1610
|
+
const declaredNumberFormat = getZodDefStringProperty(unwrapped, 'format');
|
|
1611
|
+
if (typeof declaredNumberFormat === 'string' && declaredNumberFormat.includes('int')) {
|
|
1612
|
+
result.isInt = true;
|
|
1613
|
+
result.step = 1;
|
|
1614
|
+
}
|
|
1615
|
+
const checks = getZodDefArrayProperty(unwrapped, 'checks') ?? [];
|
|
1035
1616
|
for (const check of checks) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
const kind = check.kind;
|
|
1039
|
-
const value = check.value;
|
|
1040
|
-
if (kind === 'min' && typeof value === 'number') {
|
|
1041
|
-
result.min = value;
|
|
1042
|
-
}
|
|
1043
|
-
if (kind === 'max' && typeof value === 'number') {
|
|
1044
|
-
result.max = value;
|
|
1045
|
-
}
|
|
1046
|
-
if (kind === 'int') {
|
|
1617
|
+
const definition = getZodCheckDefinition(check);
|
|
1618
|
+
if (definition?.check === 'number_format' && typeof definition.format === 'string' && definition.format.includes('int')) {
|
|
1047
1619
|
result.isInt = true;
|
|
1048
1620
|
result.step = 1;
|
|
1049
1621
|
}
|
|
1050
|
-
|
|
1622
|
+
}
|
|
1623
|
+
for (const check of checks) {
|
|
1624
|
+
const definition = getZodCheckDefinition(check);
|
|
1625
|
+
if (!definition)
|
|
1626
|
+
continue;
|
|
1627
|
+
const value = definition.value;
|
|
1628
|
+
if (typeof value !== 'number')
|
|
1629
|
+
continue;
|
|
1630
|
+
const inclusive = definition.inclusive === true;
|
|
1631
|
+
if (definition.check === 'greater_than') {
|
|
1632
|
+
// An EXCLUSIVE bound has no HTML equivalent: `min` is inclusive, so writing the raw value
|
|
1633
|
+
// would ADMIT a number the schema rejects — `z.number().positive()` becoming `min="0"` and
|
|
1634
|
+
// accepting zero. On an integer the next representable value is exact, so it translates; on a
|
|
1635
|
+
// float there is no honest answer and the constraint is left unstated rather than stated
|
|
1636
|
+
// wrongly. A field with no hint still validates; a field with a wrong hint invites a value
|
|
1637
|
+
// the server refuses.
|
|
1638
|
+
//
|
|
1639
|
+
// And for the same reason the result ACCUMULATES toward the tightest bound instead of being
|
|
1640
|
+
// assigned: `z.number().int().min(5).positive()` carries two lower bounds, and taking the
|
|
1641
|
+
// last would report `min: 1` for a schema that rejects 1 through 4.
|
|
1642
|
+
if (inclusive)
|
|
1643
|
+
result.min = tightestLowerBound(result.min, value);
|
|
1644
|
+
else if (result.isInt)
|
|
1645
|
+
result.min = tightestLowerBound(result.min, value + 1);
|
|
1646
|
+
}
|
|
1647
|
+
if (definition.check === 'less_than') {
|
|
1648
|
+
if (inclusive)
|
|
1649
|
+
result.max = tightestUpperBound(result.max, value);
|
|
1650
|
+
else if (result.isInt)
|
|
1651
|
+
result.max = tightestUpperBound(result.max, value - 1);
|
|
1652
|
+
}
|
|
1653
|
+
if (definition.check === 'multiple_of') {
|
|
1051
1654
|
result.step = value;
|
|
1052
1655
|
}
|
|
1053
1656
|
}
|
|
@@ -1065,17 +1668,14 @@ export function getNumberConstraints(schema) {
|
|
|
1065
1668
|
export function getEnumValues(schema) {
|
|
1066
1669
|
const unwrapped = unwrapSchema(schema);
|
|
1067
1670
|
const type = getZodType(unwrapped);
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1671
|
+
// A string literal is a vocabulary — usually of one value (`z.literal('value')`), and in Zod 4
|
|
1672
|
+
// legitimately of several (`z.literal(['a', 'b'])`), which stores every value in `def.values`.
|
|
1673
|
+
// EVERY string value is the vocabulary: reading only `values[0]` planned and worded one value of a
|
|
1674
|
+
// multi-value discriminator and rendered every other member's row raw (#1096). Non-string literals
|
|
1675
|
+
// (numbers, booleans, null) are not select-field vocabularies and contribute nothing.
|
|
1071
1676
|
if (type === 'literal') {
|
|
1072
|
-
const values = getZodDefProperty(unwrapped, 'values');
|
|
1073
|
-
|
|
1074
|
-
// Only string literals are valid enum values
|
|
1075
|
-
if (typeof literalValue === 'string') {
|
|
1076
|
-
return [literalValue];
|
|
1077
|
-
}
|
|
1078
|
-
return [];
|
|
1677
|
+
const values = getZodDefProperty(unwrapped, 'values') ?? [];
|
|
1678
|
+
return values.filter((value) => typeof value === 'string');
|
|
1079
1679
|
}
|
|
1080
1680
|
// Handle union types - combine enum values from all options
|
|
1081
1681
|
// This handles cases like z.union([z.enum(A), z.enum(B)])
|
|
@@ -1386,7 +1986,23 @@ export function getArrayItemSchema(schema) {
|
|
|
1386
1986
|
return element ?? null;
|
|
1387
1987
|
}
|
|
1388
1988
|
/**
|
|
1389
|
-
*
|
|
1989
|
+
* How many items an array schema admits — `z.array(x).min(1).max(3)`.
|
|
1990
|
+
*
|
|
1991
|
+
* READS ZOD 4, and did not until 2026-09-20: it looked for `def.minLength` / `def.maxLength`
|
|
1992
|
+
* carrying a `{ value }` object, which is the ZOD 3 shape. Zod 4 puts array bounds in `def.checks`
|
|
1993
|
+
* under a `check` discriminator (`min_length` / `max_length` / `length_equals`) with the bound
|
|
1994
|
+
* named after the constraint, exactly as the string reader above already handles. So this returned
|
|
1995
|
+
* `{}` for every array ever declared — measured, all three of `.min(1).max(2)`, `.max(1)` and no
|
|
1996
|
+
* bound at all answered the same thing.
|
|
1997
|
+
*
|
|
1998
|
+
* The cost was silent and total: an author could write `.max(3)` on a multi-select and get no
|
|
1999
|
+
* limit, no disabled options and no hint, with the only enforcement left at submit — and the one
|
|
2000
|
+
* control that read this looked, in its source, exactly like a control that honoured it.
|
|
2001
|
+
*
|
|
2002
|
+
* Bounds ACCUMULATE toward the tightest, for the same reason the string reader states: a schema
|
|
2003
|
+
* may carry several (`.min(1).nonempty()` is ordinary authoring) and the last one authored is not
|
|
2004
|
+
* the one that governs. A hint looser than the schema invites a value the server refuses, which is
|
|
2005
|
+
* worse than no hint at all.
|
|
1390
2006
|
*/
|
|
1391
2007
|
export function getArrayConstraints(schema) {
|
|
1392
2008
|
const unwrapped = unwrapSchema(schema);
|
|
@@ -1394,17 +2010,23 @@ export function getArrayConstraints(schema) {
|
|
|
1394
2010
|
return {};
|
|
1395
2011
|
}
|
|
1396
2012
|
const result = {};
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
2013
|
+
for (const check of getZodDefArrayProperty(unwrapped, 'checks') ?? []) {
|
|
2014
|
+
const definition = getZodCheckDefinition(check);
|
|
2015
|
+
if (!definition)
|
|
2016
|
+
continue;
|
|
2017
|
+
const minimum = definition.minimum;
|
|
2018
|
+
const maximum = definition.maximum;
|
|
2019
|
+
const exactLength = definition.length;
|
|
2020
|
+
if (definition.check === 'min_length' && typeof minimum === 'number') {
|
|
2021
|
+
result.minLength = tightestLowerBound(result.minLength, minimum);
|
|
2022
|
+
}
|
|
2023
|
+
if (definition.check === 'max_length' && typeof maximum === 'number') {
|
|
2024
|
+
result.maxLength = tightestUpperBound(result.maxLength, maximum);
|
|
2025
|
+
}
|
|
2026
|
+
if (definition.check === 'length_equals' && typeof exactLength === 'number') {
|
|
2027
|
+
result.minLength = tightestLowerBound(result.minLength, exactLength);
|
|
2028
|
+
result.maxLength = tightestUpperBound(result.maxLength, exactLength);
|
|
2029
|
+
}
|
|
1408
2030
|
}
|
|
1409
2031
|
return result;
|
|
1410
2032
|
}
|
|
@@ -1412,7 +2034,21 @@ export function getArrayConstraints(schema) {
|
|
|
1412
2034
|
// RECORD/MAP SCHEMA UTILITIES
|
|
1413
2035
|
// ------------------------------------------------------------
|
|
1414
2036
|
/**
|
|
1415
|
-
* Get the value schema from a record schema
|
|
2037
|
+
* Get the value schema from a record schema.
|
|
2038
|
+
*
|
|
2039
|
+
* A record's keys are DATA rather than declared fields, so it has no `shape` and
|
|
2040
|
+
* {@link getObjectShape} correctly answers `null` for one. What a consumer still needs is the
|
|
2041
|
+
* single schema every entry's value shares — enough to render each entry through the ordinary
|
|
2042
|
+
* field dispatch instead of as raw text.
|
|
2043
|
+
*
|
|
2044
|
+
* That is not hypothetical: `FieldSchemaType.RECORD` had no display branch at all until
|
|
2045
|
+
* 2026-09-18, so a record field fell through `DisplayValueDispatcher` to its "unsupported type"
|
|
2046
|
+
* chip and rendered an ERROR over data that was fully present — measured on four settings panes
|
|
2047
|
+
* (an organization's `Limits` and `Limit overrides`, and SSO's `Domain verification status` on
|
|
2048
|
+
* both the organization and application surfaces). This function existed the whole time; the
|
|
2049
|
+
* display simply never called it.
|
|
2050
|
+
*
|
|
2051
|
+
* Unwraps first, so an `.optional()` / `.default()` / `.nullable()` record answers like a bare one.
|
|
1416
2052
|
*/
|
|
1417
2053
|
export function getRecordValueSchema(schema) {
|
|
1418
2054
|
const unwrapped = unwrapSchema(schema);
|
|
@@ -1422,6 +2058,59 @@ export function getRecordValueSchema(schema) {
|
|
|
1422
2058
|
const valueSchema = getZodDefSchemaProperty(unwrapped, 'valueType');
|
|
1423
2059
|
return valueSchema ?? null;
|
|
1424
2060
|
}
|
|
2061
|
+
/**
|
|
2062
|
+
* The KEY schema of a RECORD, or `null` when the schema is not a record — {@link getRecordValueSchema}'s twin.
|
|
2063
|
+
*
|
|
2064
|
+
* A record's keys are data, but they can still belong to a named vocabulary: an organization's `limits` is keyed by
|
|
2065
|
+
* limit identifiers, declared `z.record(z.string().systemEnum('Limit'), …)`, and a display reads each key's word
|
|
2066
|
+
* through the vocabulary the key schema names (#1851) instead of printing the identifier.
|
|
2067
|
+
*
|
|
2068
|
+
* Unwraps first, like {@link getRecordValueSchema}.
|
|
2069
|
+
*/
|
|
2070
|
+
export function getRecordKeySchema(schema) {
|
|
2071
|
+
const unwrapped = unwrapSchema(schema);
|
|
2072
|
+
if (getZodType(unwrapped) !== 'record') {
|
|
2073
|
+
return null;
|
|
2074
|
+
}
|
|
2075
|
+
const keySchema = getZodDefSchemaProperty(unwrapped, 'keyType');
|
|
2076
|
+
return keySchema ?? null;
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* The element schema of a SET, or `null` when the schema is not a set.
|
|
2080
|
+
*
|
|
2081
|
+
* Sibling of {@link getRecordValueSchema} and {@link getMapSchemas}, and added for the same reason
|
|
2082
|
+
* that one records: `FieldSchemaType.SET` classified correctly and had no display branch, so a set
|
|
2083
|
+
* field fell through `DisplayValueDispatcher` to its "unsupported type" chip and rendered an ERROR
|
|
2084
|
+
* over data that was fully present. A set is an ARRAY without order or duplicates, so its element
|
|
2085
|
+
* schema is the only thing a renderer needs to treat it as one.
|
|
2086
|
+
*
|
|
2087
|
+
* Unwraps first, so an `.optional()` / `.default()` / `.nullable()` set answers like a bare one.
|
|
2088
|
+
*/
|
|
2089
|
+
export function getSetValueSchema(schema) {
|
|
2090
|
+
const unwrapped = unwrapSchema(schema);
|
|
2091
|
+
if (getZodType(unwrapped) !== 'set') {
|
|
2092
|
+
return null;
|
|
2093
|
+
}
|
|
2094
|
+
return getZodDefSchemaProperty(unwrapped, 'valueType') ?? null;
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* The positional item schemas of a TUPLE, or `null` when the schema is not a tuple.
|
|
2098
|
+
*
|
|
2099
|
+
* A tuple differs from an array in exactly one way that matters to a renderer: each POSITION has
|
|
2100
|
+
* its own schema, so the items cannot share one element type. Returning the list lets a display
|
|
2101
|
+
* branch pair `value[i]` with `items[i]` and render each position as the type it actually is,
|
|
2102
|
+
* rather than formatting the whole tuple as text.
|
|
2103
|
+
*
|
|
2104
|
+
* Unwraps first, like its siblings.
|
|
2105
|
+
*/
|
|
2106
|
+
export function getTupleItemSchemas(schema) {
|
|
2107
|
+
const unwrapped = unwrapSchema(schema);
|
|
2108
|
+
if (getZodType(unwrapped) !== 'tuple') {
|
|
2109
|
+
return null;
|
|
2110
|
+
}
|
|
2111
|
+
const items = getZodDefProperty(unwrapped, 'items');
|
|
2112
|
+
return Array.isArray(items) ? items : null;
|
|
2113
|
+
}
|
|
1425
2114
|
/**
|
|
1426
2115
|
* Get the key and value schemas from a map schema
|
|
1427
2116
|
*/
|
|
@@ -1869,6 +2558,45 @@ export function getUniqueFieldParams(schema) {
|
|
|
1869
2558
|
return { scope: 'global', sparse: true }; // Default params from original getUniqueParams
|
|
1870
2559
|
return value;
|
|
1871
2560
|
}
|
|
2561
|
+
/**
|
|
2562
|
+
* The NAME of the field an object schema marks as its primary key, or `undefined`.
|
|
2563
|
+
*
|
|
2564
|
+
* ## Why this is not `resourceFieldIdentifier`
|
|
2565
|
+
*
|
|
2566
|
+
* A resource has two different id-shaped names and they are not interchangeable:
|
|
2567
|
+
*
|
|
2568
|
+
* - `resourceFieldIdentifier` (`organizationSsoConfigId`, `todoId`) is the URL PARAMETER name. It
|
|
2569
|
+
* appears in routes and in contextual parameters. A response payload does not generally carry it.
|
|
2570
|
+
* - the PRIMARY KEY field (`_id`) is what the row calls its own identity, and is what a payload
|
|
2571
|
+
* actually contains.
|
|
2572
|
+
*
|
|
2573
|
+
* Conflating them is a real defect rather than a hypothetical one. `ResourceAutomaticDisplayer`'s
|
|
2574
|
+
* READ-bootstrap fallback — the path that exists so a row without an id in its URL can still be
|
|
2575
|
+
* identified once its data arrives — read `data[resourceFieldIdentifier]`. For an ordinary resource
|
|
2576
|
+
* the fallback never runs (the id is in the URL, so the earlier branch returns), and for a
|
|
2577
|
+
* CONTEXT-ADDRESSED SINGLETON, which is the only shape that needs it, the key is absent from the
|
|
2578
|
+
* payload and the lookup yields `undefined`.
|
|
2579
|
+
*
|
|
2580
|
+
* Measured 2026-09-19 on the running application: `organizationSsoConfig` loads
|
|
2581
|
+
* `["_id", "enforcement", "domains", …]` while the fallback asked for `organizationSsoConfigId`. So
|
|
2582
|
+
* the resource id stayed undefined after a successful READ, the mutation manager could not attach
|
|
2583
|
+
* (it is keyed on that id), and `canInlineEdit` was false — which is why every settings singleton in
|
|
2584
|
+
* the product was silently read-only, and why declaring `inlineEditable` on one would have changed
|
|
2585
|
+
* nothing.
|
|
2586
|
+
*
|
|
2587
|
+
* Returns the FIRST field marked `.isPrimaryKey()`. A schema declaring several is malformed; a
|
|
2588
|
+
* schema declaring none — a DTO projection, a nested object — yields `undefined`, and every caller
|
|
2589
|
+
* treats that as "no id", which is the safe reading.
|
|
2590
|
+
*/
|
|
2591
|
+
export function getPrimaryKeyFieldName(schema) {
|
|
2592
|
+
const shape = getZodObjectShape(schema);
|
|
2593
|
+
for (const [fieldName, fieldSchema] of Object.entries(shape)) {
|
|
2594
|
+
if (isPrimaryKeyField(fieldSchema)) {
|
|
2595
|
+
return fieldName;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
return undefined;
|
|
2599
|
+
}
|
|
1872
2600
|
/**
|
|
1873
2601
|
* Check if a schema field has the isPrimaryKey decorator (recursive traversal through wrappers).
|
|
1874
2602
|
*/
|
|
@@ -2140,6 +2868,30 @@ export function getDurationConstraints(schema) {
|
|
|
2140
2868
|
allowedUnits: metadata.allowedUnits,
|
|
2141
2869
|
};
|
|
2142
2870
|
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Check if a schema is an address schema (created by z_address()).
|
|
2873
|
+
* Detects by the presence of addressConfig in SCHEMA_METADATA, on the schema or its unwrapped inner schema.
|
|
2874
|
+
*/
|
|
2875
|
+
export function isAddressSchema(schema) {
|
|
2876
|
+
return getAddressMetadata(schema) !== undefined;
|
|
2877
|
+
}
|
|
2878
|
+
/**
|
|
2879
|
+
* Get the address metadata from a schema, or undefined when it is not an address schema.
|
|
2880
|
+
*/
|
|
2881
|
+
export function getAddressMetadata(schema) {
|
|
2882
|
+
const schemaMetadata = schema[SCHEMA_METADATA];
|
|
2883
|
+
if (schemaMetadata?.addressConfig?.isAddress === true) {
|
|
2884
|
+
return schemaMetadata.addressConfig;
|
|
2885
|
+
}
|
|
2886
|
+
const unwrapped = unwrapSchema(schema);
|
|
2887
|
+
if (unwrapped !== schema) {
|
|
2888
|
+
const unwrappedMetadata = unwrapped[SCHEMA_METADATA];
|
|
2889
|
+
if (unwrappedMetadata?.addressConfig?.isAddress === true) {
|
|
2890
|
+
return unwrappedMetadata.addressConfig;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
return undefined;
|
|
2894
|
+
}
|
|
2143
2895
|
/**
|
|
2144
2896
|
* Check if a schema is a phone schema (created by z_phone()).
|
|
2145
2897
|
* Detects by the presence of phoneConfig in SCHEMA_METADATA.
|
|
@@ -2489,6 +3241,18 @@ const ENUM_EDIT_MODE_BY_FORM_COMPONENT = {
|
|
|
2489
3241
|
[FormFieldComponent.SELECT]: EnumEditMode.SELECT,
|
|
2490
3242
|
[FormFieldComponent.RADIO_GROUP]: EnumEditMode.RADIO,
|
|
2491
3243
|
};
|
|
3244
|
+
/**
|
|
3245
|
+
* The MULTIPLE-selection twin of the map above, for the other `edit` axis an author can use.
|
|
3246
|
+
*
|
|
3247
|
+
* `CHECKBOX_GROUP` is deliberately absent from the single-select map — the test
|
|
3248
|
+
* `does NOT map CHECKBOX_GROUP onto an enum mode` pins that, because a checkbox group is an
|
|
3249
|
+
* array-of-enum control and has no single-select meaning. It belongs HERE, where it does, and it
|
|
3250
|
+
* is the only member: `FormFieldComponent` has no `COMBOBOX`, which is why the searchable multi
|
|
3251
|
+
* had to borrow `EnumEditMode.COMBOBOX` before this vocabulary existed.
|
|
3252
|
+
*/
|
|
3253
|
+
const ENUM_MULTIPLE_EDIT_MODE_BY_FORM_COMPONENT = {
|
|
3254
|
+
[FormFieldComponent.CHECKBOX_GROUP]: EnumMultipleEditMode.CHECKBOX,
|
|
3255
|
+
};
|
|
2492
3256
|
const NUMBER_EDIT_MODE_BY_FORM_COMPONENT = {
|
|
2493
3257
|
[FormFieldComponent.INPUT]: NumberEditMode.INPUT,
|
|
2494
3258
|
[FormFieldComponent.SLIDER]: NumberEditMode.SLIDER,
|
|
@@ -2517,6 +3281,21 @@ export function resolveEnumEditMode(schema) {
|
|
|
2517
3281
|
const component = getFormFieldComponent(schema);
|
|
2518
3282
|
return (component !== undefined ? ENUM_EDIT_MODE_BY_FORM_COMPONENT[component] : undefined) ?? EnumEditMode.SELECT;
|
|
2519
3283
|
}
|
|
3284
|
+
/**
|
|
3285
|
+
* Resolve the shape an ARRAY OF ENUM VALUES edits as.
|
|
3286
|
+
*
|
|
3287
|
+
* Takes the ELEMENT schema, which is where `enumUI()` lives. The default is CHECKBOX and not the
|
|
3288
|
+
* single vocabulary's SELECT: a checkbox group is the one multiple control that says "any number
|
|
3289
|
+
* of these" without the reader having to open it first, and it is what an undeclared enum array
|
|
3290
|
+
* has always rendered as.
|
|
3291
|
+
*/
|
|
3292
|
+
export function resolveEnumMultipleEditMode(itemSchema) {
|
|
3293
|
+
const declared = getEnumUI(itemSchema)?.edit?.multipleEditMode;
|
|
3294
|
+
if (declared !== undefined)
|
|
3295
|
+
return declared;
|
|
3296
|
+
const component = getFormFieldComponent(itemSchema);
|
|
3297
|
+
return (component !== undefined ? ENUM_MULTIPLE_EDIT_MODE_BY_FORM_COMPONENT[component] : undefined) ?? EnumMultipleEditMode.CHECKBOX;
|
|
3298
|
+
}
|
|
2520
3299
|
/** Resolve the shape a number field edits as, honoring both edit axes. */
|
|
2521
3300
|
export function resolveNumberEditMode(schema) {
|
|
2522
3301
|
const declared = getNumberUI(schema)?.edit?.editMode;
|
|
@@ -2641,11 +3420,140 @@ export function isNotEditableForOperation(schema, operation) {
|
|
|
2641
3420
|
return false;
|
|
2642
3421
|
}
|
|
2643
3422
|
/**
|
|
2644
|
-
*
|
|
2645
|
-
*
|
|
3423
|
+
* The readable stand-in for an unresolvable foreign key, taken from the record that HOLDS it.
|
|
3424
|
+
*
|
|
3425
|
+
* Returns the value of the sibling named by `foreignKeyUI.display.labelFallbackField`, or
|
|
3426
|
+
* `undefined` when the field declares none, the record is absent, or the sibling is empty. Every
|
|
3427
|
+
* caller treats `undefined` as "fall through to the id", which is the behaviour before this existed
|
|
3428
|
+
* — so a field that declares nothing renders exactly as it did.
|
|
3429
|
+
*
|
|
3430
|
+
* Non-string siblings are refused rather than coerced: a label is text, and `String(someObject)`
|
|
3431
|
+
* would put `[object Object]` on screen, which is worse than the id it replaced.
|
|
3432
|
+
*/
|
|
3433
|
+
export function resolveForeignKeyLabelFallback(schema, record) {
|
|
3434
|
+
if (!schema || !record)
|
|
3435
|
+
return undefined;
|
|
3436
|
+
const fallbackField = getForeignKeyUIDisplay(schema)?.labelFallbackField;
|
|
3437
|
+
if (!fallbackField)
|
|
3438
|
+
return undefined;
|
|
3439
|
+
const raw = record[fallbackField];
|
|
3440
|
+
return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* WHY this field has nothing to show — see {@link DisplayEmptyReason}.
|
|
3444
|
+
*
|
|
3445
|
+
* `isCollectionEmptiness` is supplied by the CALL SITE, because only the renderer knows which kind
|
|
3446
|
+
* of emptiness it met: the dispatcher reaches its empty branches separately for a null scalar, a
|
|
3447
|
+
* zero-length array, a keyless object and an entryless record. Re-deriving that from the value here
|
|
3448
|
+
* would be a second, weaker answer to a question the caller has already answered exactly — and it
|
|
3449
|
+
* would get `[]` on a field whose schema says OBJECT wrong.
|
|
3450
|
+
*
|
|
3451
|
+
* The remaining split is `.excludeFromUpdate()`: a field the UPDATE contract strips is one the
|
|
3452
|
+
* operator cannot fill, so its emptiness is the system's to resolve, not theirs. That predicate is
|
|
3453
|
+
* the same one {@link isEligibleForLayoutDefaultInlineEdit} uses to decide whether to offer a
|
|
3454
|
+
* control, which is what keeps the two answers consistent: a field that gets no control also does
|
|
3455
|
+
* not get a word inviting input.
|
|
3456
|
+
*/
|
|
3457
|
+
export function resolveDisplayEmptyReason(schema, isCollectionEmptiness) {
|
|
3458
|
+
if (isCollectionEmptiness)
|
|
3459
|
+
return DisplayEmptyReason.NONE;
|
|
3460
|
+
if (schema && isExcludedFromUpdate(schema))
|
|
3461
|
+
return DisplayEmptyReason.NOT_PROVIDED;
|
|
3462
|
+
return DisplayEmptyReason.NOT_SET;
|
|
3463
|
+
}
|
|
3464
|
+
/**
|
|
3465
|
+
* The component label key that words a given empty reason.
|
|
3466
|
+
*
|
|
3467
|
+
* One named mapping rather than a ternary at each of the dispatcher's four empty branches — the
|
|
3468
|
+
* shape `.claude/rules/invariant-call-site-enumeration.md` exists to refuse. It is TOTAL over the
|
|
3469
|
+
* vocabulary (`Record<DisplayEmptyReason, string>`), so a reason added tomorrow is a compile error
|
|
3470
|
+
* here rather than a silent fallthrough to the generic word.
|
|
3471
|
+
*
|
|
3472
|
+
* `noValue` is deliberately KEPT as the NOT_SET key rather than renamed. It is already declared,
|
|
3473
|
+
* already realised in every application's generated tree, and already the right words for that
|
|
3474
|
+
* case; renaming it would restale every label unit in every application to say the same thing.
|
|
3475
|
+
*/
|
|
3476
|
+
export const DISPLAY_EMPTY_REASON_LABEL_KEYS = {
|
|
3477
|
+
[DisplayEmptyReason.NOT_SET]: 'noValue',
|
|
3478
|
+
[DisplayEmptyReason.NONE]: 'noValueNone',
|
|
3479
|
+
[DisplayEmptyReason.NOT_PROVIDED]: 'noValueNotProvided',
|
|
3480
|
+
};
|
|
3481
|
+
/**
|
|
3482
|
+
* Does this field DECLARE that it is inline-editable?
|
|
3483
|
+
*
|
|
3484
|
+
* Reads `{primitive}UI.display.inlineEditable`, which is `boolean | {…}`.
|
|
3485
|
+
*
|
|
3486
|
+
* ## `false` means FALSE — corrected 2026-09-19
|
|
3487
|
+
*
|
|
3488
|
+
* This used to be `inlineEditable != null`, so an authored `inlineEditable: false` answered TRUE
|
|
3489
|
+
* and the field was routed to `DisplayFieldEditable` exactly like an opted-IN one. There,
|
|
3490
|
+
* `getInlineEditableParams` returns `undefined` for `false` and the component falls back to
|
|
3491
|
+
* `DEFAULT_INLINE_EDIT_PARAMS` — so the explicit opt-out produced the DEFAULT inline editor. An
|
|
3492
|
+
* author writing `false` got the opposite of what they asked for, with nothing to grep for.
|
|
3493
|
+
*
|
|
3494
|
+
* Nothing in the repository authored `false` at the time of the fix (measured: zero occurrences
|
|
3495
|
+
* across `engine/`, `platform/` and both dogfood applications), so no behaviour changed on the day.
|
|
3496
|
+
* What changed is that the opt-out became REAL, which is what
|
|
3497
|
+
* {@link ResourceLayoutInlineEditDefault} needs: a layout-level default is only safe if a single
|
|
3498
|
+
* field can decline it.
|
|
2646
3499
|
*/
|
|
2647
3500
|
export function hasInlineEditableDecorator(schema) {
|
|
2648
|
-
return getFieldUIDisplayConfig(schema)?.inlineEditable != null
|
|
3501
|
+
return getFieldUIDisplayConfig(schema)?.inlineEditable != null
|
|
3502
|
+
&& getFieldUIDisplayConfig(schema)?.inlineEditable !== false;
|
|
3503
|
+
}
|
|
3504
|
+
/**
|
|
3505
|
+
* Has this field explicitly DECLINED inline editing?
|
|
3506
|
+
*
|
|
3507
|
+
* Distinct from `!hasInlineEditableDecorator(schema)`, and the distinction is the whole point of
|
|
3508
|
+
* the layout-level default: a field that says nothing is a candidate for the default, while a field
|
|
3509
|
+
* that says `false` has opted out and must stay read-only even under a layout that turns the
|
|
3510
|
+
* default on.
|
|
3511
|
+
*/
|
|
3512
|
+
export function declinesInlineEdit(schema) {
|
|
3513
|
+
return getFieldUIDisplayConfig(schema)?.inlineEditable === false;
|
|
3514
|
+
}
|
|
3515
|
+
/**
|
|
3516
|
+
* Would a layout whose `defaultInlineEdit` is on make THIS field editable in place?
|
|
3517
|
+
*
|
|
3518
|
+
* The predicate is derived from what the field already declares about itself, rather than from a
|
|
3519
|
+
* hand-kept list of editable field names — so a field added to a settings schema tomorrow is
|
|
3520
|
+
* covered without anyone remembering to add it, and a field that is not writable is never offered a
|
|
3521
|
+
* control that would fail on save.
|
|
3522
|
+
*
|
|
3523
|
+
* A field qualifies when ALL of these hold:
|
|
3524
|
+
*
|
|
3525
|
+
* - it has not DECLINED (`inlineEditable: false`) — an explicit author decision outranks a default;
|
|
3526
|
+
* - it is not `.excludeFromUpdate()` — the UPDATE DTO strips it, so a control here would collect a
|
|
3527
|
+
* value the server silently discards. That is worse than no control, because the row appears to
|
|
3528
|
+
* save and does not;
|
|
3529
|
+
* - it is not `.isHidden()` for display — a field nobody may read is not a field anybody may write.
|
|
3530
|
+
*
|
|
3531
|
+
* ## The primary key needs no clause of its own — measured, not assumed
|
|
3532
|
+
*
|
|
3533
|
+
* An `isPrimaryKey(schema)` guard was written here first, on the reasoning that an identity is not a
|
|
3534
|
+
* setting. It is UNREACHABLE: `.isPrimaryKey()` already reports `isHiddenForDisplay() === true`, so
|
|
3535
|
+
* the hidden clause below decides every primary key before a dedicated clause could. Deleting it
|
|
3536
|
+
* left all ten pins green, which is how it was found.
|
|
3537
|
+
*
|
|
3538
|
+
* It is removed rather than kept "for safety". An unreachable guard is not defence in depth — it is
|
|
3539
|
+
* an untested claim that reads like coverage (`.claude/rules/invariant-call-site-enumeration.md`
|
|
3540
|
+
* #19, #25), and the next author would have reasonably assumed it was load-bearing. The N/A is
|
|
3541
|
+
* stated here instead. If a primary key ever stops being hidden for display, this is the paragraph
|
|
3542
|
+
* that has to be re-read.
|
|
3543
|
+
*
|
|
3544
|
+
* Note what is deliberately NOT tested: the field's TYPE. A boolean, a string, an enum and an array
|
|
3545
|
+
* are all legitimately editable in place, and the display dispatcher already routes each to the
|
|
3546
|
+
* right control (`DisplayFieldEditableArray` for arrays, the switch fast-path for booleans). Adding
|
|
3547
|
+
* a type filter here would silently re-narrow what the layout author asked for.
|
|
3548
|
+
*/
|
|
3549
|
+
export function isEligibleForLayoutDefaultInlineEdit(schema) {
|
|
3550
|
+
if (declinesInlineEdit(schema))
|
|
3551
|
+
return false;
|
|
3552
|
+
if (isExcludedFromUpdate(schema))
|
|
3553
|
+
return false;
|
|
3554
|
+
if (isHiddenForDisplay(schema))
|
|
3555
|
+
return false;
|
|
3556
|
+
return true;
|
|
2649
3557
|
}
|
|
2650
3558
|
/**
|
|
2651
3559
|
* Get the resolved inline editable params from `{primitive}UI.display.inlineEditable`.
|
|
@@ -2907,7 +3815,11 @@ export function serializeZodSchema(schema, seen = new Set()) {
|
|
|
2907
3815
|
const literalValues = (getZodDefArrayProperty(inner, 'values') ?? [getZodDefProperty(inner, 'value')])
|
|
2908
3816
|
.filter((entry) => entry !== undefined && entry !== null);
|
|
2909
3817
|
const value = literalValues[0];
|
|
2910
|
-
|
|
3818
|
+
// A multi-value literal (`z.literal(['a', 'b'])`) carries EVERY value it admits (#1106): serialized
|
|
3819
|
+
// as `value: 'a'` alone it round-tripped as `z.literal('a')` and refused `b`. `value` stays the first
|
|
3820
|
+
// entry for the single-value case every reader already understands; `values` is present only when
|
|
3821
|
+
// there is more than one, and the deserializer prefers it.
|
|
3822
|
+
return withOptionals(withMeta({ type: 'literal', value, ...(literalValues.length > 1 ? { values: literalValues } : {}) }), optional, nullable);
|
|
2911
3823
|
}
|
|
2912
3824
|
case 'enum': {
|
|
2913
3825
|
// In Zod 4, both z.enum() and z.enum() use type 'enum'
|
|
@@ -3068,7 +3980,9 @@ export function deserializeZodSchema(desc) {
|
|
|
3068
3980
|
return applyOptionalNullable(s, d);
|
|
3069
3981
|
}
|
|
3070
3982
|
case 'literal': {
|
|
3071
|
-
|
|
3983
|
+
// `values` is the whole admitted set of a multi-value literal (#1106); `value` alone is a single one.
|
|
3984
|
+
const literalValues = d.values;
|
|
3985
|
+
const s = applyMetadata(z.literal((literalValues && literalValues.length > 1 ? literalValues : d.value)), d.metadata);
|
|
3072
3986
|
return applyOptionalNullable(s, d);
|
|
3073
3987
|
}
|
|
3074
3988
|
case 'enum': {
|