@tai42/studio-sdk 22.3.0 → 22.4.1

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.
Files changed (57) hide show
  1. package/dist/guards.d.ts +6 -0
  2. package/dist/guards.d.ts.map +1 -1
  3. package/dist/guards.js +8 -0
  4. package/dist/guards.js.map +1 -1
  5. package/dist/hooks/useApi.d.ts +3 -0
  6. package/dist/hooks/useApi.d.ts.map +1 -1
  7. package/dist/schema-form/SchemaForm.d.ts +4 -1
  8. package/dist/schema-form/SchemaForm.d.ts.map +1 -1
  9. package/dist/schema-form/SchemaForm.js.map +1 -1
  10. package/dist/schema-form/classify.js +3 -3
  11. package/dist/schema-form/classify.js.map +1 -1
  12. package/dist/schema-form/default-value.js +5 -4
  13. package/dist/schema-form/default-value.js.map +1 -1
  14. package/dist/schema-form/field-model.d.ts +3 -3
  15. package/dist/schema-form/field-model.d.ts.map +1 -1
  16. package/dist/schema-form/field-node.d.ts +1 -0
  17. package/dist/schema-form/field-node.d.ts.map +1 -1
  18. package/dist/schema-form/field-node.js +2 -1
  19. package/dist/schema-form/field-node.js.map +1 -1
  20. package/dist/schema-form/multiselect-field.d.ts +1 -1
  21. package/dist/schema-form/object-fields.d.ts +1 -0
  22. package/dist/schema-form/object-fields.d.ts.map +1 -1
  23. package/dist/schema-form/object-fields.js +4 -3
  24. package/dist/schema-form/object-fields.js.map +1 -1
  25. package/dist/schema-form/record-field.d.ts.map +1 -1
  26. package/dist/schema-form/record-field.js +4 -2
  27. package/dist/schema-form/record-field.js.map +1 -1
  28. package/dist/schema-form/string-field.js +2 -2
  29. package/dist/schema-form/string-field.js.map +1 -1
  30. package/dist/schema-form/union-field.d.ts.map +1 -1
  31. package/dist/schema-form/union-field.js +3 -8
  32. package/dist/schema-form/union-field.js.map +1 -1
  33. package/dist/schema-form/union.js +1 -1
  34. package/dist/schema-form/union.js.map +1 -1
  35. package/dist/schema-form/validate.d.ts +14 -1
  36. package/dist/schema-form/validate.d.ts.map +1 -1
  37. package/dist/schema-form/validate.js +99 -45
  38. package/dist/schema-form/validate.js.map +1 -1
  39. package/dist/schema-form/visibility.d.ts +0 -12
  40. package/dist/schema-form/visibility.d.ts.map +1 -1
  41. package/dist/schema-form/visibility.js +14 -1
  42. package/dist/schema-form/visibility.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/guards.ts +12 -0
  45. package/src/schema-form/SchemaForm.tsx +4 -2
  46. package/src/schema-form/classify.ts +3 -3
  47. package/src/schema-form/default-value.ts +5 -4
  48. package/src/schema-form/field-model.ts +3 -3
  49. package/src/schema-form/field-node.tsx +4 -3
  50. package/src/schema-form/multiselect-field.tsx +1 -1
  51. package/src/schema-form/object-fields.tsx +8 -7
  52. package/src/schema-form/record-field.tsx +4 -2
  53. package/src/schema-form/string-field.tsx +2 -2
  54. package/src/schema-form/union-field.tsx +5 -6
  55. package/src/schema-form/union.ts +1 -1
  56. package/src/schema-form/validate.ts +127 -46
  57. package/src/schema-form/visibility.ts +4 -3
@@ -66,18 +66,19 @@ function seedObject(
66
66
  root: JsonSchema,
67
67
  mintScalars: boolean,
68
68
  ): Record<string, unknown> {
69
- const result: Record<string, unknown> = {};
69
+ const entries: [string, unknown][] = [];
70
70
  for (const [name, propSchema] of model.properties) {
71
71
  if (model.required.has(name)) {
72
72
  const seeded = seedValue(propSchema, root, mintScalars);
73
- if (seeded !== undefined) result[name] = seeded;
73
+ if (seeded !== undefined) entries.push([name, seeded]);
74
74
  continue;
75
75
  }
76
76
  const propClassified = classifySchema(propSchema, root);
77
77
  const propDefault = explicitDefault(propSchema, propClassified.schema);
78
- if (propDefault.present) result[name] = propDefault.value;
78
+ if (propDefault.present) entries.push([name, propDefault.value]);
79
79
  }
80
- return result;
80
+ // Built from entries so every property name, `__proto__` included, lands as an own key.
81
+ return Object.fromEntries(entries);
81
82
  }
82
83
 
83
84
  /**
@@ -29,8 +29,8 @@ export interface MediaUpload {
29
29
  }
30
30
 
31
31
  /**
32
- * The platform date constraints classified off a `date`/`date-time` string property
33
- * (scope C). `min`/`max` are inclusive `YYYY-MM-DD` bounds (passed to the native date
32
+ * The platform date constraints classified off a `date`/`date-time` string property.
33
+ * `min`/`max` are inclusive `YYYY-MM-DD` bounds (passed to the native date
34
34
  * control and enforced by the validator); `unavailable` is the set of excluded days
35
35
  * (the native control cannot disable arbitrary days, so this is validator-enforced).
36
36
  * A date RANGE is two ordinary date fields: on the END field, `rangeStart` names its
@@ -143,7 +143,7 @@ export type FieldModel =
143
143
  * field into the jq expression editor (see {@link ExpressionAnnotation}). */
144
144
  readonly expression: ExpressionAnnotation | undefined;
145
145
  /** Present on a `date`/`date-time` string carrying any platform date
146
- * constraint (bounds, unavailable days, or range pairing — scope C). */
146
+ * constraint (bounds, unavailable days, or range pairing). */
147
147
  readonly date: DateConstraints | undefined;
148
148
  }
149
149
  | { readonly kind: 'number'; readonly integer: boolean }
@@ -9,6 +9,7 @@ import { memo, type ReactNode } from 'react';
9
9
  import { Checkbox } from '../components/checkbox';
10
10
  import { Field } from '../components/field';
11
11
  import { NumberInput, TextInput } from '../components/inputs';
12
+ import { ownValue } from '../guards';
12
13
  import { ArrayField } from './array-field';
13
14
  import { classifySchema } from './classify';
14
15
  import { EnumField } from './enum-field';
@@ -46,7 +47,7 @@ interface FieldNodeProps {
46
47
  * its direct `enum`-select children draw their placeholder. Carried by the root form
47
48
  * only; nested objects receive none.
48
49
  */
49
- readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
50
+ readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string; invalid?: string }>>;
50
51
  /** This field's own picker placeholder, when it draws as an `enum` select. */
51
52
  readonly choiceLabel?: string;
52
53
  }
@@ -73,7 +74,7 @@ function FieldNodeImpl(props: FieldNodeProps): ReactNode {
73
74
  nullable: classified.nullable,
74
75
  heading,
75
76
  description,
76
- error: errors?.[path],
77
+ error: ownValue(errors, path),
77
78
  root,
78
79
  value,
79
80
  onChange,
@@ -100,7 +101,7 @@ interface RenderFieldArgs {
100
101
  readonly required: boolean;
101
102
  readonly errors: SchemaFormErrors | undefined;
102
103
  readonly idPrefix: string;
103
- readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
104
+ readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string; invalid?: string }>>;
104
105
  readonly choiceLabel?: string;
105
106
  }
106
107
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The multi-select field renderer (scope E): an array of a fixed set of strings drawn
2
+ * The multi-select field renderer: an array of a fixed set of strings drawn
3
3
  * as a checkbox group, one checkbox per option, emitting a `string[]`. Classification
4
4
  * lands an array here only when its items carry a known option set (an `enum`, or a
5
5
  * per-send option list), so there is always a definite set of boxes to draw; an array of
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { ReactNode } from 'react';
8
8
 
9
+ import { ownValue } from '../guards';
9
10
  import { DateRangeField, dateRangePairs } from './date-range-field';
10
11
  import { FieldNode } from './field-node';
11
12
  import { useKeyedCallbacks } from './keyed-callbacks';
@@ -45,7 +46,7 @@ export function ObjectFields({
45
46
  * object only; a property whose `enum` draws as a select shows its entry's
46
47
  * `choiceLabel` as the select placeholder. Absent for nested objects.
47
48
  */
48
- fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
49
+ fieldWording?: Readonly<Record<string, { choiceLabel?: string; invalid?: string }>>;
49
50
  }): ReactNode {
50
51
  const obj = isPlainObject(value) ? value : {};
51
52
  const changeFor = useKeyedCallbacks<string, unknown>(
@@ -66,7 +67,7 @@ export function ObjectFields({
66
67
  {properties.map(([name, propSchema]) => {
67
68
  if (skip !== undefined && name === skip) return null;
68
69
  if (skippedEnds.has(name)) return null;
69
- // A field hidden by its `visibleWhen` predicate (scope B) is not rendered; the
70
+ // A field hidden by its `visibleWhen` predicate is not rendered; the
70
71
  // validator skips it in step, so a hidden field is neither shown nor required.
71
72
  if (!isFieldVisible(propSchema, obj)) return null;
72
73
  const pair = pairByStart.get(name);
@@ -75,13 +76,13 @@ export function ObjectFields({
75
76
  <DateRangeField
76
77
  key={name}
77
78
  pair={pair}
78
- startValue={obj[pair.startName]}
79
- endValue={obj[pair.endName]}
79
+ startValue={ownValue(obj, pair.startName)}
80
+ endValue={ownValue(obj, pair.endName)}
80
81
  startRequired={requiredKeys.has(pair.startName)}
81
82
  endRequired={requiredKeys.has(pair.endName)}
82
83
  markRequired={markRequired}
83
- startError={errors?.[childPathOf(pair.startName)]}
84
- endError={errors?.[childPathOf(pair.endName)]}
84
+ startError={ownValue(errors, childPathOf(pair.startName))}
85
+ endError={ownValue(errors, childPathOf(pair.endName))}
85
86
  onChangeStart={changeFor(pair.startName)}
86
87
  onChangeEnd={changeFor(pair.endName)}
87
88
  />
@@ -92,7 +93,7 @@ export function ObjectFields({
92
93
  key={name}
93
94
  schema={propSchema}
94
95
  root={root}
95
- value={obj[name]}
96
+ value={ownValue(obj, name)}
96
97
  onChange={changeFor(name)}
97
98
  path={childPathOf(name)}
98
99
  label={name}
@@ -43,7 +43,7 @@ interface Analysis {
43
43
  }
44
44
 
45
45
  function analyzeRows(rows: readonly Row[]): Analysis {
46
- const value: Record<string, unknown> = {};
46
+ const entries: [string, unknown][] = [];
47
47
  const seen = new Set<string>();
48
48
  const blankRowIds = new Set<number>();
49
49
  const duplicateRowIds = new Set<number>();
@@ -57,8 +57,10 @@ function analyzeRows(rows: readonly Row[]): Analysis {
57
57
  continue;
58
58
  }
59
59
  seen.add(row.key);
60
- value[row.key] = row.value;
60
+ entries.push([row.key, row.value]);
61
61
  }
62
+ // Built from entries so every typed key, `__proto__` included, lands as an own key.
63
+ const value = Object.fromEntries(entries);
62
64
  return { value, blankRowIds, duplicateRowIds };
63
65
  }
64
66
 
@@ -142,7 +142,7 @@ export function StringField({
142
142
  // numeric/decimal keyboard); `undefined` for every other control, which
143
143
  // selects its kind through `type` instead.
144
144
  inputMode={control.inputMode}
145
- // A native date control enforces its inclusive bounds (scope C); on a
145
+ // A native date control enforces its inclusive bounds; on a
146
146
  // non-date input the browser ignores these, and the validator enforces the
147
147
  // rest (unavailable days, range span) that the control cannot draw.
148
148
  min={date?.min}
@@ -421,7 +421,7 @@ const INPUT_MODE_CONTROLS: Record<
421
421
  };
422
422
 
423
423
  // The native control for a string field: the `inputMode` keyword OUTRANKS
424
- // `format` when present; otherwise the format mapping stands as before. The value
424
+ // `format` when present; otherwise the format mapping picks the control. The value
425
425
  // is always the typed string — `numeric`/`decimal` set only the keyboard, never a
426
426
  // number coercion.
427
427
  function stringControl(
@@ -65,12 +65,11 @@ function switchVariantValue(
65
65
  const { model } = classifySchema(target.schema, root);
66
66
  if (model.kind !== 'object') return seeded;
67
67
  const targetNames = new Set(model.properties.map(([name]) => name));
68
- const merged: Record<string, unknown> = { ...seeded };
69
- for (const [name, held] of Object.entries(current)) {
70
- if (name === discriminator) continue;
71
- if (targetNames.has(name)) merged[name] = held;
72
- }
73
- return merged;
68
+ const carried = Object.entries(current).filter(
69
+ ([name]) => name !== discriminator && targetNames.has(name),
70
+ );
71
+ // Built from entries so every property name, `__proto__` included, lands as an own key.
72
+ return Object.fromEntries([...Object.entries(seeded), ...carried]);
74
73
  }
75
74
 
76
75
  /** Title-case a property name: `kind` → "Kind", `content_type` → "Content Type". */
@@ -67,7 +67,7 @@ export function activeVariantIndex(
67
67
  const required = resolved.required ?? [];
68
68
  let score = 0;
69
69
  for (const key of required) {
70
- if (key in value) score += 1;
70
+ if (Object.hasOwn(value, key)) score += 1;
71
71
  }
72
72
  if (score > bestScore) {
73
73
  bestScore = score;
@@ -5,7 +5,7 @@
5
5
  * container constraint on a free-form `json` field. It never throws on a
6
6
  * malformed value — a malformed value IS the error it reports.
7
7
  */
8
- import { isRecord } from '../guards';
8
+ import { isRecord, ownValue } from '../guards';
9
9
  import { classifySchema } from './classify';
10
10
  import type { DateConstraints, FieldModel } from './field-model';
11
11
  import { decodedByteSize, effectiveMaxBytes, overCapMessage } from './media';
@@ -22,16 +22,62 @@ export interface ValidateOptions {
22
22
  * absent both, the shared default applies.
23
23
  */
24
24
  readonly maxUploadBytes?: number;
25
+ /**
26
+ * Per-field words, keyed by the root object's exact property name. A field's
27
+ * `invalid` sentence replaces every shape error on that field and anything inside
28
+ * it (a format, pattern, length, bound, type or item error). A missing required
29
+ * value keeps its own required message, and a `pattern` that cannot compile keeps
30
+ * its own message, since that is a defect of the schema, not of the value. A field
31
+ * with no `invalid` sentence keeps the SDK's word for the failing shape.
32
+ */
33
+ readonly fieldWording?: Readonly<Record<string, { invalid?: string }>>;
25
34
  }
26
35
 
27
36
  /** The invariants every per-node validator shares while walking one value tree. */
28
37
  interface ValidateCtx {
29
38
  /** The document root, for `$ref` resolution during re-classification. */
30
39
  readonly root: JsonSchema;
31
- /** The path-keyed error bag every validator writes into. */
40
+ /**
41
+ * The path-keyed error bag every validator writes into. It has no prototype, so a
42
+ * path that names a built-in object member (`toString`, `__proto__`) is an ordinary key.
43
+ */
32
44
  readonly errors: Record<string, string>;
33
45
  /** The host's default media byte cap (a field's own cap still overrides it). */
34
46
  readonly maxUploadBytes: number | undefined;
47
+ /**
48
+ * The root object's property the walk is inside, or `undefined` at the root itself
49
+ * and inside a root that is not an object or record.
50
+ */
51
+ readonly field: string | undefined;
52
+ /**
53
+ * The paths whose error is a shape error a field's `invalid` sentence may replace,
54
+ * each mapped to the root property it lies inside.
55
+ */
56
+ readonly shapeErrorFields: Map<string, string>;
57
+ }
58
+
59
+ /**
60
+ * Record a shape error at `path`: the value breaks a shape the schema declares. Inside
61
+ * a root property, that field's `invalid` sentence may replace it.
62
+ */
63
+ function shapeError(ctx: ValidateCtx, path: string, message: string): void {
64
+ ctx.errors[path] = message;
65
+ if (ctx.field === undefined) ctx.shapeErrorFields.delete(path);
66
+ else ctx.shapeErrorFields.set(path, ctx.field);
67
+ }
68
+
69
+ /**
70
+ * Record an error at `path` that is not a shape the value breaks (a missing required
71
+ * value, a schema constraint that cannot run): a field's words never replace it.
72
+ */
73
+ function keptError(ctx: ValidateCtx, path: string, message: string): void {
74
+ ctx.errors[path] = message;
75
+ ctx.shapeErrorFields.delete(path);
76
+ }
77
+
78
+ /** The context for the child `name` of the value at `path`: a root child is its own field. */
79
+ function childCtx(ctx: ValidateCtx, path: string, name: string): ValidateCtx {
80
+ return path === '' ? { ...ctx, field: name } : ctx;
35
81
  }
36
82
 
37
83
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -78,9 +124,9 @@ function validateJson(
78
124
  ctx: ValidateCtx,
79
125
  ): void {
80
126
  if (model.jsonType === 'object' && !isRecord(value)) {
81
- ctx.errors[path] = 'must be a JSON object';
127
+ shapeError(ctx, path, 'must be a JSON object');
82
128
  } else if (model.jsonType === 'array' && !Array.isArray(value)) {
83
- ctx.errors[path] = 'must be a JSON array';
129
+ shapeError(ctx, path, 'must be a JSON array');
84
130
  }
85
131
  }
86
132
 
@@ -90,7 +136,7 @@ function validateConst(
90
136
  path: string,
91
137
  ctx: ValidateCtx,
92
138
  ): void {
93
- if (value !== model.value) ctx.errors[path] = `must equal ${scalarLabel(model.value)}`;
139
+ if (value !== model.value) shapeError(ctx, path, `must equal ${scalarLabel(model.value)}`);
94
140
  }
95
141
 
96
142
  function validateEnum(
@@ -100,7 +146,7 @@ function validateEnum(
100
146
  ctx: ValidateCtx,
101
147
  ): void {
102
148
  const allowed = model.options.some((option) => option.value === value);
103
- if (!allowed) ctx.errors[path] = 'must be one of the allowed values';
149
+ if (!allowed) shapeError(ctx, path, 'must be one of the allowed values');
104
150
  }
105
151
 
106
152
  function validateString(
@@ -110,7 +156,7 @@ function validateString(
110
156
  ctx: ValidateCtx,
111
157
  ): void {
112
158
  if (typeof value !== 'string') {
113
- ctx.errors[path] = 'must be a string';
159
+ shapeError(ctx, path, 'must be a string');
114
160
  return;
115
161
  }
116
162
  // A media-annotated string carries a byte cap; enforce it on the value's
@@ -121,22 +167,22 @@ function validateString(
121
167
  const cap = effectiveMaxBytes(model.media.maxBytes, ctx.maxUploadBytes);
122
168
  const size = decodedByteSize(value);
123
169
  if (size > cap) {
124
- ctx.errors[path] = overCapMessage('The value', size, cap);
170
+ shapeError(ctx, path, overCapMessage('The value', size, cap));
125
171
  return;
126
172
  }
127
173
  }
128
174
  if (value.length > 0 && model.format !== undefined) {
129
175
  const message = formatError(model.format, value);
130
176
  if (message !== undefined) {
131
- ctx.errors[path] = message;
177
+ shapeError(ctx, path, message);
132
178
  return;
133
179
  }
134
180
  }
135
- // Date bounds and unavailable days (scope C). Range span/ordering needs the sibling
181
+ // Date bounds and unavailable days. Range span/ordering needs the sibling
136
182
  // start field, so it is enforced at the object level (see validateDateRanges).
137
183
  if (model.date !== undefined && value.length > 0) {
138
184
  const message = dateConstraintError(model.date, value);
139
- if (message !== undefined) ctx.errors[path] = message;
185
+ if (message !== undefined) shapeError(ctx, path, message);
140
186
  }
141
187
  }
142
188
 
@@ -159,14 +205,14 @@ function validateNumber(
159
205
  ctx: ValidateCtx,
160
206
  ): void {
161
207
  if (typeof value !== 'number' || Number.isNaN(value)) {
162
- ctx.errors[path] = model.integer ? 'must be an integer' : 'must be a number';
208
+ shapeError(ctx, path, model.integer ? 'must be an integer' : 'must be a number');
163
209
  return;
164
210
  }
165
- if (model.integer && !Number.isInteger(value)) ctx.errors[path] = 'must be an integer';
211
+ if (model.integer && !Number.isInteger(value)) shapeError(ctx, path, 'must be an integer');
166
212
  }
167
213
 
168
214
  function validateBoolean(value: unknown, path: string, ctx: ValidateCtx): void {
169
- if (typeof value !== 'boolean') ctx.errors[path] = 'must be true or false';
215
+ if (typeof value !== 'boolean') shapeError(ctx, path, 'must be true or false');
170
216
  }
171
217
 
172
218
  function validateArray(
@@ -176,7 +222,7 @@ function validateArray(
176
222
  ctx: ValidateCtx,
177
223
  ): void {
178
224
  if (!Array.isArray(value)) {
179
- ctx.errors[path] = 'must be an array';
225
+ shapeError(ctx, path, 'must be an array');
180
226
  return;
181
227
  }
182
228
  value.forEach((item, index) => {
@@ -191,40 +237,45 @@ function validateObject(
191
237
  ctx: ValidateCtx,
192
238
  ): void {
193
239
  if (!isRecord(value)) {
194
- ctx.errors[path] = 'must be an object';
240
+ shapeError(ctx, path, 'must be an object');
195
241
  return;
196
242
  }
197
243
  const propMap = new Map(model.properties);
198
244
  for (const [name, propSchema] of model.properties) {
199
- // A field hidden by its `visibleWhen` predicate (scope B) is not part of the answer:
245
+ // A field hidden by its `visibleWhen` predicate is not part of the answer:
200
246
  // it is not required and not validated, and a value left on it is dropped by the
201
247
  // server, not an error here. Evaluated on the current values, so a field that was
202
248
  // hidden by the predicate is skipped even if it still carries a stale value.
203
249
  if (!isFieldVisible(propSchema, value)) continue;
204
250
  const childPath = joinPath(path, name);
205
- const childValue = value[name];
251
+ const childValue = ownValue(value, name);
206
252
  if (childValue === undefined) {
207
- if (model.required.has(name)) ctx.errors[childPath] = `"${name}" is required`;
253
+ if (model.required.has(name)) markRequired(childPath, name, ctx);
208
254
  continue;
209
255
  }
210
- walk(propSchema, childValue, childPath, ctx);
256
+ walk(propSchema, childValue, childPath, childCtx(ctx, path, name));
211
257
  }
212
258
  // Required keys naming a property that somehow isn't declared still count.
213
259
  for (const name of model.required) {
214
- if (!propMap.has(name) && value[name] === undefined) {
215
- ctx.errors[joinPath(path, name)] = `"${name}" is required`;
260
+ if (!propMap.has(name) && ownValue(value, name) === undefined) {
261
+ markRequired(joinPath(path, name), name, ctx);
216
262
  }
217
263
  }
218
264
  validateDateRanges(model, value, path, ctx);
219
265
  }
220
266
 
267
+ /** Record a missing required value at `path`, the one error a field's words never replace. */
268
+ function markRequired(path: string, name: string, ctx: ValidateCtx): void {
269
+ keptError(ctx, path, `"${name}" is required`);
270
+ }
271
+
221
272
  /** Days from `start` to `end` inclusive (both `YYYY-MM-DD`); 1 when they are equal. */
222
273
  function inclusiveDayCount(start: string, end: string): number {
223
274
  return Math.round((Date.parse(end) - Date.parse(start)) / 86_400_000) + 1;
224
275
  }
225
276
 
226
277
  /**
227
- * Enforce a date RANGE declared on an END field (scope C): the end is on or after its
278
+ * Enforce a date RANGE declared on an END field: the end is on or after its
228
279
  * named `rangeStart` field, and the inclusive day span is within `minDays`/`maxDays`.
229
280
  * Read from the two plain date values — nothing recombined. A missing or not-yet-valid
230
281
  * endpoint is left to that field's own validation; a hidden end field is skipped.
@@ -239,14 +290,14 @@ function validateDateRanges(
239
290
  if (!isFieldVisible(propSchema, value)) continue;
240
291
  const resolved = resolveRef(propSchema, ctx.root);
241
292
  if (typeof resolved.rangeStart !== 'string') continue;
242
- const endValue = value[name];
243
- const startValue = value[resolved.rangeStart];
293
+ const endValue = ownValue(value, name);
294
+ const startValue = ownValue(value, resolved.rangeStart);
244
295
  if (typeof endValue !== 'string' || typeof startValue !== 'string') continue;
245
296
  if (!DATE_RE.test(endValue) || !DATE_RE.test(startValue)) continue;
246
297
  const childPath = joinPath(path, name);
247
298
  if (ctx.errors[childPath] !== undefined) continue;
248
299
  const message = dateRangeMessage(resolved, startValue, endValue);
249
- if (message !== undefined) ctx.errors[childPath] = message;
300
+ if (message !== undefined) shapeError(childCtx(ctx, path, name), childPath, message);
250
301
  }
251
302
  }
252
303
 
@@ -273,20 +324,20 @@ function validateMultiselect(
273
324
  ctx: ValidateCtx,
274
325
  ): void {
275
326
  if (!Array.isArray(value)) {
276
- ctx.errors[path] = 'must be an array';
327
+ shapeError(ctx, path, 'must be an array');
277
328
  return;
278
329
  }
279
330
  const allowed = new Set(model.options.map((option) => option.value));
280
331
  for (const item of value) {
281
332
  if (!allowed.has(item)) {
282
- ctx.errors[path] = 'must be one of the allowed values';
333
+ shapeError(ctx, path, 'must be one of the allowed values');
283
334
  return;
284
335
  }
285
336
  }
286
337
  }
287
338
 
288
339
  /**
289
- * The JSON-Schema value/length/items bounds the client mirrors from the server (bug 4):
340
+ * The JSON-Schema value/length/items bounds the client mirrors from the server:
290
341
  * `minLength`/`maxLength`/`pattern` on a string, `minimum`/`maximum` on a number,
291
342
  * `minItems`/`maxItems` on an array. Applied only when the kind check found no type
292
343
  * error. A `pattern` that cannot compile surfaces as a loud field error, never a silent
@@ -306,11 +357,11 @@ function applyStringBounds(
306
357
  ): void {
307
358
  const length = Array.from(value).length;
308
359
  if (typeof schema.minLength === 'number' && length < schema.minLength) {
309
- ctx.errors[path] = `must be at least ${String(schema.minLength)} character(s)`;
360
+ shapeError(ctx, path, `must be at least ${String(schema.minLength)} character(s)`);
310
361
  return;
311
362
  }
312
363
  if (typeof schema.maxLength === 'number' && length > schema.maxLength) {
313
- ctx.errors[path] = `must be at most ${String(schema.maxLength)} character(s)`;
364
+ shapeError(ctx, path, `must be at most ${String(schema.maxLength)} character(s)`);
314
365
  return;
315
366
  }
316
367
  if (typeof schema.pattern !== 'string' || schema.pattern === '') return;
@@ -318,10 +369,10 @@ function applyStringBounds(
318
369
  try {
319
370
  pattern = new RegExp(schema.pattern);
320
371
  } catch {
321
- ctx.errors[path] = 'has an invalid pattern constraint';
372
+ keptError(ctx, path, 'has an invalid pattern constraint');
322
373
  return;
323
374
  }
324
- if (!pattern.test(value)) ctx.errors[path] = 'must match the required pattern';
375
+ if (!pattern.test(value)) shapeError(ctx, path, 'must match the required pattern');
325
376
  }
326
377
 
327
378
  function applyNumberBounds(
@@ -331,11 +382,11 @@ function applyNumberBounds(
331
382
  ctx: ValidateCtx,
332
383
  ): void {
333
384
  if (typeof schema.minimum === 'number' && value < schema.minimum) {
334
- ctx.errors[path] = `must be at least ${String(schema.minimum)}`;
385
+ shapeError(ctx, path, `must be at least ${String(schema.minimum)}`);
335
386
  return;
336
387
  }
337
388
  if (typeof schema.maximum === 'number' && value > schema.maximum) {
338
- ctx.errors[path] = `must be at most ${String(schema.maximum)}`;
389
+ shapeError(ctx, path, `must be at most ${String(schema.maximum)}`);
339
390
  }
340
391
  }
341
392
 
@@ -346,11 +397,11 @@ function applyArrayBounds(
346
397
  ctx: ValidateCtx,
347
398
  ): void {
348
399
  if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
349
- ctx.errors[path] = `must have at least ${String(schema.minItems)} item(s)`;
400
+ shapeError(ctx, path, `must have at least ${String(schema.minItems)} item(s)`);
350
401
  return;
351
402
  }
352
403
  if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
353
- ctx.errors[path] = `must have at most ${String(schema.maxItems)} item(s)`;
404
+ shapeError(ctx, path, `must have at most ${String(schema.maxItems)} item(s)`);
354
405
  }
355
406
  }
356
407
 
@@ -361,11 +412,11 @@ function validateRecord(
361
412
  ctx: ValidateCtx,
362
413
  ): void {
363
414
  if (!isRecord(value)) {
364
- ctx.errors[path] = 'must be an object';
415
+ shapeError(ctx, path, 'must be an object');
365
416
  return;
366
417
  }
367
418
  for (const [key, entryValue] of Object.entries(value)) {
368
- walk(model.values, entryValue, joinPath(path, key), ctx);
419
+ walk(model.values, entryValue, joinPath(path, key), childCtx(ctx, path, key));
369
420
  }
370
421
  }
371
422
 
@@ -378,7 +429,7 @@ function validateUnion(
378
429
  const index = activeVariantIndex(value, model.variants, model.discriminator, ctx.root);
379
430
  const variant = index === -1 ? undefined : model.variants[index];
380
431
  if (variant === undefined) {
381
- ctx.errors[path] = 'does not match any allowed variant';
432
+ shapeError(ctx, path, 'does not match any allowed variant');
382
433
  return;
383
434
  }
384
435
  walk(variant.schema, value, path, ctx);
@@ -388,12 +439,12 @@ function walk(schema: JsonSchema, value: unknown, path: string, ctx: ValidateCtx
388
439
  const classified = classifySchema(schema, ctx.root);
389
440
 
390
441
  if (value === null) {
391
- if (!classified.nullable) ctx.errors[path] = 'must not be null';
442
+ if (!classified.nullable) shapeError(ctx, path, 'must not be null');
392
443
  return;
393
444
  }
394
445
 
395
446
  dispatchKind(classified.model, value, path, ctx);
396
- // The value/length/items bounds (bug 4) mirror the server and apply only when the kind
447
+ // The value/length/items bounds mirror the server and apply only when the kind
397
448
  // check found no type error for this field — a bound on a wrong-typed value is noise.
398
449
  if (ctx.errors[path] === undefined) applyBounds(classified.schema, value, path, ctx);
399
450
  }
@@ -436,18 +487,48 @@ function dispatchKind(model: FieldModel, value: unknown, path: string, ctx: Vali
436
487
  }
437
488
  }
438
489
 
490
+ /**
491
+ * Replace each shape error inside a root property whose words carry an `invalid`
492
+ * sentence with that sentence; every other error keeps its own message.
493
+ */
494
+ function applyFieldWording(
495
+ fieldWording: Readonly<Record<string, { invalid?: string }>>,
496
+ errors: Record<string, string>,
497
+ shapeErrorFields: ReadonlyMap<string, string>,
498
+ ): void {
499
+ for (const [path, field] of shapeErrorFields) {
500
+ if (!Object.hasOwn(fieldWording, field)) continue;
501
+ const invalid = fieldWording[field]?.invalid;
502
+ if (invalid !== undefined) errors[path] = invalid;
503
+ }
504
+ }
505
+
439
506
  /**
440
507
  * Validate `value` against `schema`, returning a per-path error bag (empty =
441
508
  * valid). The caller runs this before submit and feeds the result back to
442
509
  * `SchemaForm` for display. `options.maxUploadBytes` mirrors the renderer's
443
- * `maxUploadBytes` prop so a media field's byte cap is enforced identically here.
510
+ * `maxUploadBytes` prop so a media field's byte cap is enforced identically here;
511
+ * `options.fieldWording` puts a field's own `invalid` sentence in place of the
512
+ * SDK's word for any shape error on that field.
444
513
  */
445
514
  export function validateAgainstSchema(
446
515
  schema: JsonSchema,
447
516
  value: unknown,
448
517
  options?: ValidateOptions,
449
518
  ): SchemaFormErrors {
450
- const errors: Record<string, string> = {};
451
- walk(schema, value, '', { root: schema, errors, maxUploadBytes: options?.maxUploadBytes });
452
- return errors;
519
+ const errors = Object.create(null) as Record<string, string>;
520
+ const shapeErrorFields = new Map<string, string>();
521
+ walk(schema, value, '', {
522
+ root: schema,
523
+ errors,
524
+ maxUploadBytes: options?.maxUploadBytes,
525
+ field: undefined,
526
+ shapeErrorFields,
527
+ });
528
+ if (options?.fieldWording !== undefined) {
529
+ applyFieldWording(options.fieldWording, errors, shapeErrorFields);
530
+ }
531
+ // Hand back an ordinary object; its entries are defined as own keys, so a path such as
532
+ // `__proto__` stays an entry of the result.
533
+ return Object.fromEntries(Object.entries(errors));
453
534
  }
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * The single evaluator for a property's `visibleWhen` predicate — a static,
3
- * declarative field-visibility rule (scope B). The renderer (`ObjectFields`) and the
3
+ * declarative field-visibility rule. The renderer (`ObjectFields`) and the
4
4
  * validator both call it so a field shown by the form is exactly the field validated at
5
5
  * submit, and a hidden field is neither rendered nor required. It reads only sibling
6
- * values already in the object; it never calls a consumer handler (that is scope A).
6
+ * values already in the object; it never calls a consumer handler.
7
7
  *
8
8
  * A malformed predicate degrades to VISIBLE: the send-time server validation rejects a
9
9
  * malformed predicate loudly, so one never reaches a well-formed render, and showing a
10
10
  * field is the safe non-hiding default (a hidden-by-accident required field would dead-
11
11
  * end a form).
12
12
  */
13
+ import { ownValue } from '../guards';
13
14
  import type { JsonSchema } from './types';
14
15
 
15
16
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -33,7 +34,7 @@ function isEmptyValue(value: unknown): boolean {
33
34
  export function isFieldVisible(schema: JsonSchema, values: Record<string, unknown>): boolean {
34
35
  const predicate: unknown = schema.visibleWhen;
35
36
  if (!isRecord(predicate) || typeof predicate.field !== 'string') return true;
36
- const current = values[predicate.field];
37
+ const current = ownValue(values, predicate.field);
37
38
 
38
39
  const inList = predicate.in;
39
40
  const hasEquals = 'equals' in predicate;