@synnaxlabs/x 0.54.0 → 0.54.2
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/.turbo/turbo-build.log +7 -7
- package/dist/src/binary/codec.d.ts.map +1 -1
- package/dist/src/color/color.d.ts +1 -1
- package/dist/src/color/palette.d.ts +2 -2
- package/dist/src/deep/atKeys.d.ts +27 -0
- package/dist/src/deep/atKeys.d.ts.map +1 -0
- package/dist/src/deep/atKeys.spec.d.ts +2 -0
- package/dist/src/deep/atKeys.spec.d.ts.map +1 -0
- package/dist/src/deep/external.d.ts +1 -0
- package/dist/src/deep/external.d.ts.map +1 -1
- package/dist/src/fmt/external.d.ts +3 -0
- package/dist/src/fmt/external.d.ts.map +1 -0
- package/dist/src/fmt/index.d.ts +2 -0
- package/dist/src/fmt/index.d.ts.map +1 -0
- package/dist/src/fmt/path.d.ts +13 -0
- package/dist/src/fmt/path.d.ts.map +1 -0
- package/dist/src/fmt/path.spec.d.ts +2 -0
- package/dist/src/fmt/path.spec.d.ts.map +1 -0
- package/dist/src/fmt/value.d.ts +23 -0
- package/dist/src/fmt/value.d.ts.map +1 -0
- package/dist/src/fmt/value.spec.d.ts +2 -0
- package/dist/src/fmt/value.spec.d.ts.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/label/types.gen.d.ts +2 -2
- package/dist/src/narrow/narrow.d.ts +9 -0
- package/dist/src/narrow/narrow.d.ts.map +1 -1
- package/dist/src/primitive/primitive.d.ts +10 -0
- package/dist/src/primitive/primitive.d.ts.map +1 -1
- package/dist/src/status/status.d.ts +14 -2
- package/dist/src/status/status.d.ts.map +1 -1
- package/dist/src/status/types.gen.d.ts +1 -1
- package/dist/src/telem/series.d.ts +4 -4
- package/dist/src/telem/telem.d.ts +10 -10
- package/dist/src/zod/external.d.ts +1 -0
- package/dist/src/zod/external.d.ts.map +1 -1
- package/dist/src/zod/parse.d.ts +47 -0
- package/dist/src/zod/parse.d.ts.map +1 -0
- package/dist/src/zod/parse.spec.d.ts +2 -0
- package/dist/src/zod/parse.spec.d.ts.map +1 -0
- package/dist/x.cjs +16 -7
- package/dist/x.js +2190 -1869
- package/package.json +3 -3
- package/src/binary/codec.ts +3 -2
- package/src/deep/atKeys.spec.ts +107 -0
- package/src/deep/atKeys.ts +49 -0
- package/src/deep/external.ts +1 -0
- package/src/fmt/external.ts +11 -0
- package/src/fmt/index.ts +10 -0
- package/src/fmt/path.spec.ts +46 -0
- package/src/fmt/path.ts +30 -0
- package/src/fmt/value.spec.ts +206 -0
- package/src/fmt/value.ts +83 -0
- package/src/index.ts +1 -0
- package/src/narrow/narrow.spec.ts +43 -0
- package/src/narrow/narrow.ts +15 -0
- package/src/primitive/primitive.spec.ts +51 -0
- package/src/primitive/primitive.ts +12 -0
- package/src/status/status.spec.ts +152 -0
- package/src/status/status.ts +67 -19
- package/src/zod/external.ts +1 -0
- package/src/zod/parse.spec.ts +702 -0
- package/src/zod/parse.ts +519 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/zod/parse.ts
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// Copyright 2026 Synnax Labs, Inc.
|
|
2
|
+
//
|
|
3
|
+
// Use of this software is governed by the Business Source License included in the file
|
|
4
|
+
// licenses/BSL.txt.
|
|
5
|
+
//
|
|
6
|
+
// As of the Change Date specified in that file, in accordance with the Business Source
|
|
7
|
+
// License, use of this software will be governed by the Apache License, Version 2.0,
|
|
8
|
+
// included in the file licenses/APL.txt.
|
|
9
|
+
|
|
10
|
+
import { type z } from "zod";
|
|
11
|
+
|
|
12
|
+
import { deep } from "@/deep";
|
|
13
|
+
import { errors } from "@/errors";
|
|
14
|
+
import { fmt } from "@/fmt";
|
|
15
|
+
import { primitive } from "@/primitive";
|
|
16
|
+
import { type status } from "@/status";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LABEL = "value";
|
|
19
|
+
const PARSE_ERROR_TYPE = "zod.parse";
|
|
20
|
+
const MARKER = "✗";
|
|
21
|
+
const MAX_ALIGN_WIDTH = 60;
|
|
22
|
+
|
|
23
|
+
export interface ParseOptions {
|
|
24
|
+
label?: string;
|
|
25
|
+
context?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Tight defaults for sibling context values rendered inline in a parent view.
|
|
29
|
+
// Callers can override any individual field via `ParseOptions.formatOptions`; the
|
|
30
|
+
// faithful version of the input lives on `err.input` and in
|
|
31
|
+
// `toStatus().details.input` for callers that need the full structure.
|
|
32
|
+
const DEFAULT_PARENT_VIEW_OPTIONS: fmt.Options = {
|
|
33
|
+
maxStringLength: 60,
|
|
34
|
+
maxArrayLength: 3,
|
|
35
|
+
maxDepth: 3,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const parentPath = (path: ReadonlyArray<PropertyKey>): ReadonlyArray<PropertyKey> =>
|
|
39
|
+
path.slice(0, -1);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns a concise "expected X" phrase for a single zod issue. This is the core
|
|
43
|
+
* description used everywhere: in the margin-annotated parent view as the reason
|
|
44
|
+
* text, and as a prefix for the flat root-level form that appends `, received Y`.
|
|
45
|
+
*
|
|
46
|
+
* The `received` value is intentionally NOT part of this phrase; callers that need
|
|
47
|
+
* to show it append it separately depending on whether they have a local value or
|
|
48
|
+
* the root itself is the failing one.
|
|
49
|
+
*/
|
|
50
|
+
const describeCore = (issue: z.core.$ZodIssue): string => {
|
|
51
|
+
switch (issue.code) {
|
|
52
|
+
case "invalid_type":
|
|
53
|
+
return `expected ${issue.expected}`;
|
|
54
|
+
case "invalid_value":
|
|
55
|
+
return `expected one of ${JSON.stringify(issue.values)}`;
|
|
56
|
+
case "unrecognized_keys":
|
|
57
|
+
return "unexpected key";
|
|
58
|
+
case "too_small": {
|
|
59
|
+
const op = issue.inclusive === false ? ">" : ">=";
|
|
60
|
+
return `${issue.origin} too small: expected ${op}${issue.minimum}`;
|
|
61
|
+
}
|
|
62
|
+
case "too_big": {
|
|
63
|
+
const op = issue.inclusive === false ? "<" : "<=";
|
|
64
|
+
return `${issue.origin} too large: expected ${op}${issue.maximum}`;
|
|
65
|
+
}
|
|
66
|
+
case "invalid_format":
|
|
67
|
+
return `expected ${issue.format} format`;
|
|
68
|
+
case "not_multiple_of":
|
|
69
|
+
return `expected multiple of ${issue.divisor}`;
|
|
70
|
+
case "custom":
|
|
71
|
+
return issue.message || "custom validation failed";
|
|
72
|
+
default:
|
|
73
|
+
return issue.message;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A "expected X, received Y" phrase for root-level issues where the whole input is
|
|
79
|
+
* the failing value. Only reached for path-less issues since `expandUnrecognizedKeys`
|
|
80
|
+
* pushes any `unrecognized_keys` issues into the parent-view path before this runs.
|
|
81
|
+
*/
|
|
82
|
+
const describeFlat = (
|
|
83
|
+
issue: z.core.$ZodIssue,
|
|
84
|
+
root: unknown,
|
|
85
|
+
options: fmt.Options,
|
|
86
|
+
): string => {
|
|
87
|
+
const received = JSON.stringify(fmt.value(root, options));
|
|
88
|
+
return `${describeCore(issue)}, received ${received}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the maximum path depth reached by any issue within a list, recursing into
|
|
93
|
+
* nested invalid_union / invalid_key / invalid_element branches. Used to pick the
|
|
94
|
+
* union branch that got furthest before failing, which is usually the variant the
|
|
95
|
+
* caller actually intended.
|
|
96
|
+
*/
|
|
97
|
+
const branchDepth = (issues: ReadonlyArray<z.core.$ZodIssue>): number => {
|
|
98
|
+
let max = 0;
|
|
99
|
+
for (const issue of issues) {
|
|
100
|
+
if (issue.path.length > max) max = issue.path.length;
|
|
101
|
+
if (issue.code === "invalid_union" && "errors" in issue)
|
|
102
|
+
for (const branch of issue.errors) {
|
|
103
|
+
const d = branchDepth(branch);
|
|
104
|
+
if (d > max) max = d;
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
(issue.code === "invalid_key" || issue.code === "invalid_element") &&
|
|
108
|
+
"issues" in issue
|
|
109
|
+
) {
|
|
110
|
+
const d = branchDepth(issue.issues);
|
|
111
|
+
if (d > max) max = d;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return max;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Walks a list of zod issues and expands every nested-issue container into its
|
|
119
|
+
* leaves, with the outer path prepended:
|
|
120
|
+
*
|
|
121
|
+
* - `invalid_union` → picks the deepest-reaching branch and flattens it
|
|
122
|
+
* - `invalid_element` → flattens nested issues, prepending the failing element's
|
|
123
|
+
* key as a path segment (so errors point at `record[badKey]` directly)
|
|
124
|
+
* - `invalid_key` → flattens nested issues at the outer path, since the "bad key"
|
|
125
|
+
* has no distinct position in the input
|
|
126
|
+
*
|
|
127
|
+
* The result is a flat list of leaf issues pointing at specific locations in the
|
|
128
|
+
* input.
|
|
129
|
+
*/
|
|
130
|
+
const flattenIssues = (
|
|
131
|
+
issues: ReadonlyArray<z.core.$ZodIssue>,
|
|
132
|
+
basePath: ReadonlyArray<PropertyKey> = [],
|
|
133
|
+
): z.core.$ZodIssue[] => {
|
|
134
|
+
const out: z.core.$ZodIssue[] = [];
|
|
135
|
+
for (const issue of issues) {
|
|
136
|
+
const fullPath = [...basePath, ...issue.path];
|
|
137
|
+
if (
|
|
138
|
+
issue.code === "invalid_union" &&
|
|
139
|
+
"errors" in issue &&
|
|
140
|
+
issue.errors.length > 0
|
|
141
|
+
) {
|
|
142
|
+
let best = issue.errors[0];
|
|
143
|
+
let bestDepth = branchDepth(best);
|
|
144
|
+
for (let i = 1; i < issue.errors.length; i++) {
|
|
145
|
+
const d = branchDepth(issue.errors[i]);
|
|
146
|
+
if (d > bestDepth) {
|
|
147
|
+
best = issue.errors[i];
|
|
148
|
+
bestDepth = d;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
out.push(...flattenIssues(best, fullPath));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
issue.code === "invalid_element" &&
|
|
156
|
+
"issues" in issue &&
|
|
157
|
+
"key" in issue &&
|
|
158
|
+
(typeof issue.key === "string" || typeof issue.key === "number")
|
|
159
|
+
) {
|
|
160
|
+
out.push(...flattenIssues(issue.issues, [...fullPath, issue.key]));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (issue.code === "invalid_key" && "issues" in issue) {
|
|
164
|
+
out.push(...flattenIssues(issue.issues, fullPath));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
out.push({ ...issue, path: fullPath } as z.core.$ZodIssue);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Expands unrecognized_keys issues into per-key issues so each bad key becomes its
|
|
174
|
+
* own mark in the parent view. After expansion each synthesized issue points at a
|
|
175
|
+
* single offending key via its path.
|
|
176
|
+
*/
|
|
177
|
+
const expandUnrecognizedKeys = (
|
|
178
|
+
issues: ReadonlyArray<z.core.$ZodIssue>,
|
|
179
|
+
): z.core.$ZodIssue[] => {
|
|
180
|
+
const out: z.core.$ZodIssue[] = [];
|
|
181
|
+
for (const issue of issues) {
|
|
182
|
+
if (
|
|
183
|
+
issue.code === "unrecognized_keys" &&
|
|
184
|
+
"keys" in issue &&
|
|
185
|
+
Array.isArray(issue.keys)
|
|
186
|
+
) {
|
|
187
|
+
for (const key of issue.keys)
|
|
188
|
+
out.push({ ...issue, path: [...issue.path, key] } as z.core.$ZodIssue);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
out.push(issue);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
interface Mark {
|
|
197
|
+
reason: string;
|
|
198
|
+
missing: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Renders an object or array with margin markers (`✗`) on the keys that have issues,
|
|
203
|
+
* annotating each bad key with the expected type. Synthetic `<missing>` entries are
|
|
204
|
+
* appended for keys the schema expected but the input didn't contain.
|
|
205
|
+
*
|
|
206
|
+
* `baseIndent` is the column at which the opening brace sits. Marked lines place the
|
|
207
|
+
* marker two columns to the left of where the key's quote begins so that content
|
|
208
|
+
* still aligns with unmarked lines.
|
|
209
|
+
*
|
|
210
|
+
* For array parents, the truncation window is extended to include any marked
|
|
211
|
+
* index so that the reader can always see the annotated element.
|
|
212
|
+
*/
|
|
213
|
+
const renderParentView = (
|
|
214
|
+
parent: unknown,
|
|
215
|
+
marks: Map<string, Mark>,
|
|
216
|
+
baseIndent: string,
|
|
217
|
+
callerOptions: fmt.Options,
|
|
218
|
+
): string[] => {
|
|
219
|
+
const inner = `${baseIndent} `;
|
|
220
|
+
const marked = `${baseIndent + MARKER} `;
|
|
221
|
+
|
|
222
|
+
// Merge tight parent-view defaults with any caller overrides; caller fields win.
|
|
223
|
+
let options: fmt.Options = { ...DEFAULT_PARENT_VIEW_OPTIONS, ...callerOptions };
|
|
224
|
+
|
|
225
|
+
// For array parents, ensure the truncation window includes all marked indices
|
|
226
|
+
// so the ✗ lines aren't silently dropped when a bad entry lies past the
|
|
227
|
+
// effective maxArrayLength.
|
|
228
|
+
if (Array.isArray(parent)) {
|
|
229
|
+
let maxIdx = -1;
|
|
230
|
+
for (const key of marks.keys()) {
|
|
231
|
+
const idx = Number(key);
|
|
232
|
+
if (Number.isInteger(idx) && idx > maxIdx) maxIdx = idx;
|
|
233
|
+
}
|
|
234
|
+
const effectiveMax = options.maxArrayLength ?? 3;
|
|
235
|
+
if (maxIdx >= effectiveMax) options = { ...options, maxArrayLength: maxIdx + 2 };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Pass the parent through fmt.value so depth / array / string truncation is
|
|
239
|
+
// applied consistently to the rendered siblings.
|
|
240
|
+
const sanitized = fmt.value(parent, options);
|
|
241
|
+
|
|
242
|
+
// Fallback for non-container parents. Only reachable when the union flattener
|
|
243
|
+
// picks a branch whose parent path doesn't exist in the input at all: we still
|
|
244
|
+
// want to show the reader *something*, so we render the failing value and the
|
|
245
|
+
// first mark's reason on a single marker line.
|
|
246
|
+
if (sanitized == null || typeof sanitized !== "object") {
|
|
247
|
+
const val = JSON.stringify(sanitized);
|
|
248
|
+
const first = marks.values().next().value;
|
|
249
|
+
const reason = first ? ` × ${first.reason}` : "";
|
|
250
|
+
return [`${marked}${val}${reason}`];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const lines: string[] = [];
|
|
254
|
+
const isArray = Array.isArray(sanitized);
|
|
255
|
+
lines.push(`${baseIndent}${isArray ? "[" : "{"}`);
|
|
256
|
+
|
|
257
|
+
interface Entry {
|
|
258
|
+
content: string;
|
|
259
|
+
mark?: Mark;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const entries: Entry[] = [];
|
|
263
|
+
|
|
264
|
+
if (isArray) {
|
|
265
|
+
const arr = sanitized as unknown[];
|
|
266
|
+
for (let i = 0; i < arr.length; i++) {
|
|
267
|
+
const mark = marks.get(String(i));
|
|
268
|
+
entries.push({
|
|
269
|
+
content: JSON.stringify(arr[i]),
|
|
270
|
+
mark: mark && !mark.missing ? mark : undefined,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
const obj = sanitized as Record<string, unknown>;
|
|
275
|
+
for (const k of Object.keys(obj)) {
|
|
276
|
+
const mark = marks.get(k);
|
|
277
|
+
entries.push({
|
|
278
|
+
content: `"${k}": ${JSON.stringify(obj[k])}`,
|
|
279
|
+
mark: mark && !mark.missing ? mark : undefined,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
// Synthetic missing entries for fields the schema expected but the input lacks.
|
|
283
|
+
for (const [k, mark] of marks.entries())
|
|
284
|
+
if (mark.missing)
|
|
285
|
+
entries.push({
|
|
286
|
+
content: `"${k}": <missing>`,
|
|
287
|
+
mark,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Compute alignment width for the reasons, capped so that one abnormally long
|
|
292
|
+
// marked line doesn't push the column way to the right.
|
|
293
|
+
let alignWidth = 0;
|
|
294
|
+
entries.forEach((e, i) => {
|
|
295
|
+
if (e.mark == null) return;
|
|
296
|
+
const trailing = i < entries.length - 1 ? 1 : 0;
|
|
297
|
+
const w = e.content.length + trailing;
|
|
298
|
+
if (w > alignWidth && w <= MAX_ALIGN_WIDTH) alignWidth = w;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
entries.forEach((e, i) => {
|
|
302
|
+
const comma = i < entries.length - 1 ? "," : "";
|
|
303
|
+
const line = e.content + comma;
|
|
304
|
+
if (e.mark != null) {
|
|
305
|
+
const pad = " ".repeat(Math.max(2, alignWidth - line.length + 2));
|
|
306
|
+
lines.push(`${marked}${line}${pad}× ${e.mark.reason}`);
|
|
307
|
+
} else lines.push(`${inner}${line}`);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
lines.push(baseIndent + (isArray ? "]" : "}"));
|
|
311
|
+
return lines;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const ROOT_GROUP = "__root__";
|
|
315
|
+
|
|
316
|
+
const renderIssues = (
|
|
317
|
+
issues: ReadonlyArray<z.core.$ZodIssue>,
|
|
318
|
+
root: unknown,
|
|
319
|
+
options: fmt.Options,
|
|
320
|
+
): string => {
|
|
321
|
+
// Group issues by parent path. Map iteration order is insertion order, which
|
|
322
|
+
// preserves zod's traversal order without a parallel index array.
|
|
323
|
+
const groups = new Map<string, z.core.$ZodIssue[]>();
|
|
324
|
+
for (const issue of issues) {
|
|
325
|
+
const key =
|
|
326
|
+
issue.path.length === 0 ? ROOT_GROUP : `::${fmt.path(parentPath(issue.path))}`;
|
|
327
|
+
const existing = groups.get(key);
|
|
328
|
+
if (existing != null) existing.push(issue);
|
|
329
|
+
else groups.set(key, [issue]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const blocks: string[] = [];
|
|
333
|
+
for (const [key, group] of groups) {
|
|
334
|
+
// Root-level issues (path.length === 0): flat bullets, no parent view.
|
|
335
|
+
if (key === ROOT_GROUP) {
|
|
336
|
+
blocks.push(group.map((i) => ` × ${describeFlat(i, root, options)}`).join("\n"));
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const parentArr = parentPath(group[0].path);
|
|
341
|
+
const { present, value: parent } = deep.atKeys(root, parentArr);
|
|
342
|
+
|
|
343
|
+
// If the union flattener picked a branch whose parent doesn't exist in the
|
|
344
|
+
// input, we can't render a parent view. Fall back to a flat at/× block.
|
|
345
|
+
if (!present) {
|
|
346
|
+
blocks.push(
|
|
347
|
+
group
|
|
348
|
+
.map(
|
|
349
|
+
(i) => ` at ${fmt.path(i.path)}\n × ${describeFlat(i, root, options)}`,
|
|
350
|
+
)
|
|
351
|
+
.join("\n"),
|
|
352
|
+
);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Build a marks map keyed by the last path segment so renderParentView can
|
|
357
|
+
// annotate each bad key in place.
|
|
358
|
+
const marks = new Map<string, Mark>();
|
|
359
|
+
for (const issue of group) {
|
|
360
|
+
const lastKey = String(issue.path[issue.path.length - 1]);
|
|
361
|
+
const found = deep.atKeys(root, issue.path);
|
|
362
|
+
marks.set(lastKey, {
|
|
363
|
+
reason: describeCore(issue),
|
|
364
|
+
missing: !found.present,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const isRootParent = parentArr.length === 0;
|
|
369
|
+
const baseIndent = isRootParent ? " " : " ";
|
|
370
|
+
const viewLines = renderParentView(parent, marks, baseIndent, options);
|
|
371
|
+
if (isRootParent) blocks.push(viewLines.join("\n"));
|
|
372
|
+
else blocks.push([` at ${fmt.path(parentArr)}`, ...viewLines].join("\n"));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return blocks.join("\n\n");
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const formatContextLine = (
|
|
379
|
+
context: Record<string, unknown>,
|
|
380
|
+
options: fmt.Options,
|
|
381
|
+
): string => {
|
|
382
|
+
const entries = Object.entries(context);
|
|
383
|
+
if (entries.every(([, v]) => primitive.is(v)))
|
|
384
|
+
return entries
|
|
385
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? v : String(v)}`)
|
|
386
|
+
.join(", ");
|
|
387
|
+
return fmt.stringify(context, options);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
interface FormatArgs {
|
|
391
|
+
issues: ReadonlyArray<z.core.$ZodIssue>;
|
|
392
|
+
input: unknown;
|
|
393
|
+
label: string;
|
|
394
|
+
context?: Record<string, unknown>;
|
|
395
|
+
formatOptions?: fmt.Options;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const format = ({
|
|
399
|
+
issues,
|
|
400
|
+
input,
|
|
401
|
+
label,
|
|
402
|
+
context,
|
|
403
|
+
formatOptions,
|
|
404
|
+
}: FormatArgs): string => {
|
|
405
|
+
const opts = formatOptions ?? {};
|
|
406
|
+
const flat = expandUnrecognizedKeys(flattenIssues(issues));
|
|
407
|
+
const count = flat.length === 1 ? "1 issue" : `${flat.length} issues`;
|
|
408
|
+
const parts: string[] = [`Failed to parse ${label} (${count})`];
|
|
409
|
+
parts.push(renderIssues(flat, input, opts));
|
|
410
|
+
if (context != null && Object.keys(context).length > 0)
|
|
411
|
+
parts.push(` context: ${formatContextLine(context, opts)}`);
|
|
412
|
+
return parts.join("\n\n");
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
export interface ParseErrorArgs {
|
|
416
|
+
issues: ReadonlyArray<z.core.$ZodIssue>;
|
|
417
|
+
input: unknown;
|
|
418
|
+
label: string;
|
|
419
|
+
context?: Record<string, unknown>;
|
|
420
|
+
cause?: unknown;
|
|
421
|
+
formatOptions?: fmt.Options;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* An error thrown by `zod.parse` when a value fails to parse. It retains the original
|
|
426
|
+
* input, a human-readable label, and optional context so that callers and the status
|
|
427
|
+
* system can render a richer failure message than a raw `ZodError`.
|
|
428
|
+
*
|
|
429
|
+
* Extends the typed error system in `@/errors` so callers can match against it with
|
|
430
|
+
* `ParseError.matches(err)` rather than `instanceof`, which is robust across worker
|
|
431
|
+
* boundaries and network hops.
|
|
432
|
+
*
|
|
433
|
+
* Note that `err.issues.length` and the `(N issues)` count shown in `err.message` can
|
|
434
|
+
* differ: the message counts "leaves" after union/element flattening and
|
|
435
|
+
* unrecognized-keys expansion, while `err.issues` exposes the original zod array
|
|
436
|
+
* unchanged for programmatic consumers.
|
|
437
|
+
*/
|
|
438
|
+
export class ParseError
|
|
439
|
+
extends errors.createTyped(PARSE_ERROR_TYPE)
|
|
440
|
+
implements status.Custom
|
|
441
|
+
{
|
|
442
|
+
readonly issues: ReadonlyArray<z.core.$ZodIssue>;
|
|
443
|
+
readonly input: unknown;
|
|
444
|
+
readonly label: string;
|
|
445
|
+
readonly context?: Record<string, unknown>;
|
|
446
|
+
|
|
447
|
+
constructor({ issues, input, label, context, cause, formatOptions }: ParseErrorArgs) {
|
|
448
|
+
super(format({ issues, input, label, context, formatOptions }), { cause });
|
|
449
|
+
this.issues = issues;
|
|
450
|
+
this.input = input;
|
|
451
|
+
this.label = label;
|
|
452
|
+
this.context = context;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
toStatus(): Partial<status.Crude<z.ZodRecord, "error">> {
|
|
456
|
+
const details: Record<string, unknown> = {
|
|
457
|
+
input: fmt.value(this.input),
|
|
458
|
+
issues: this.issues,
|
|
459
|
+
};
|
|
460
|
+
if (this.context != null) details.context = this.context;
|
|
461
|
+
return {
|
|
462
|
+
message: `Failed to parse ${this.label}`,
|
|
463
|
+
description: this.message,
|
|
464
|
+
details,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
interface ParseErrorPayload {
|
|
470
|
+
label: string;
|
|
471
|
+
context?: Record<string, unknown>;
|
|
472
|
+
issues: ReadonlyArray<unknown>;
|
|
473
|
+
input: unknown;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
errors.register({
|
|
477
|
+
encode: (err) => {
|
|
478
|
+
if (!ParseError.matches(err)) return null;
|
|
479
|
+
const pe = err as ParseError;
|
|
480
|
+
const payload: ParseErrorPayload = {
|
|
481
|
+
label: pe.label,
|
|
482
|
+
context: pe.context,
|
|
483
|
+
issues: pe.issues,
|
|
484
|
+
input: fmt.value(pe.input),
|
|
485
|
+
};
|
|
486
|
+
return { type: PARSE_ERROR_TYPE, data: JSON.stringify(payload) };
|
|
487
|
+
},
|
|
488
|
+
decode: (payload) => {
|
|
489
|
+
if (payload.type !== PARSE_ERROR_TYPE) return null;
|
|
490
|
+
const parsed = JSON.parse(payload.data) as ParseErrorPayload;
|
|
491
|
+
return new ParseError({
|
|
492
|
+
issues: parsed.issues as ReadonlyArray<z.core.$ZodIssue>,
|
|
493
|
+
input: parsed.input,
|
|
494
|
+
label: parsed.label,
|
|
495
|
+
context: parsed.context,
|
|
496
|
+
});
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Parses `value` against `schema`. On failure, throws a `ParseError` that retains the
|
|
502
|
+
* original input along with a human-readable label and optional context fields. The
|
|
503
|
+
* error's `message` is a pre-formatted breakdown suitable for logs and status display.
|
|
504
|
+
*/
|
|
505
|
+
export const parse = <S extends z.ZodType>(
|
|
506
|
+
schema: S,
|
|
507
|
+
value: unknown,
|
|
508
|
+
options: ParseOptions = {},
|
|
509
|
+
): z.infer<S> => {
|
|
510
|
+
const result = schema.safeParse(value);
|
|
511
|
+
if (result.success) return result.data;
|
|
512
|
+
throw new ParseError({
|
|
513
|
+
issues: result.error.issues,
|
|
514
|
+
input: value,
|
|
515
|
+
label: options.label ?? DEFAULT_LABEL,
|
|
516
|
+
context: options.context,
|
|
517
|
+
cause: result.error,
|
|
518
|
+
});
|
|
519
|
+
};
|