anyvali 0.3.1 → 0.3.4

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 (40) hide show
  1. package/CHANGELOG.md +67 -44
  2. package/README.md +370 -370
  3. package/dist/parse/coerce.d.ts.map +1 -1
  4. package/dist/parse/coerce.js +14 -0
  5. package/dist/parse/coerce.js.map +1 -1
  6. package/dist/schemas/number.d.ts.map +1 -1
  7. package/dist/schemas/number.js +15 -0
  8. package/dist/schemas/number.js.map +1 -1
  9. package/dist/schemas/optional.d.ts.map +1 -1
  10. package/dist/schemas/optional.js +4 -3
  11. package/dist/schemas/optional.js.map +1 -1
  12. package/package.json +40 -40
  13. package/sdk/js/CHANGELOG.md +13 -13
  14. package/src/format/validators.ts +71 -71
  15. package/src/index.ts +285 -285
  16. package/src/infer.ts +12 -12
  17. package/src/interchange/importer.ts +285 -285
  18. package/src/issue-codes.ts +19 -19
  19. package/src/parse/coerce.ts +15 -0
  20. package/src/schemas/base.ts +322 -322
  21. package/src/schemas/intersection.ts +81 -81
  22. package/src/schemas/number.ts +17 -0
  23. package/src/schemas/object.ts +203 -203
  24. package/src/schemas/optional.ts +4 -3
  25. package/src/schemas/record.ts +55 -55
  26. package/src/schemas/string.ts +192 -192
  27. package/src/schemas/union.ts +53 -53
  28. package/src/types.ts +239 -239
  29. package/tests/unit/collections.test.ts +99 -99
  30. package/tests/unit/date-format.test.ts +18 -18
  31. package/tests/unit/default-mutation.test.ts +32 -32
  32. package/tests/unit/defaults.test.ts +70 -1
  33. package/tests/unit/inference.test.ts +306 -306
  34. package/tests/unit/interchange.test.ts +191 -191
  35. package/tests/unit/object.test.ts +208 -208
  36. package/tests/unit/security-recursion.test.ts +105 -105
  37. package/tests/unit/security.test.ts +1067 -945
  38. package/tests/unit/shared-ref-falsepos.test.ts +33 -33
  39. package/tests/unit/string-pattern-redos.test.ts +46 -46
  40. package/tests/unit/string.test.ts +147 -147
@@ -1,322 +1,322 @@
1
- import type {
2
- ParseResult,
3
- ValidationIssue,
4
- ParseContext,
5
- SchemaNode,
6
- AnyValiDocument,
7
- ExportMode,
8
- CoercionConfig,
9
- MetadataOptions,
10
- DescribeOptions,
11
- } from "../types.js";
12
- import { ValidationError } from "../errors.js";
13
- import { applyCoercion } from "../parse/coerce.js";
14
- import { ISSUE_CODES } from "../issue-codes.js";
15
-
16
- const ANYVALI_VERSION = "1.0";
17
- const SCHEMA_VERSION = "1.1";
18
-
19
- /**
20
- * Maximum validation recursion depth. Bounds recursion through recursive
21
- * `$ref` schemas (and deeply nested data) so a malicious payload cannot
22
- * exhaust the call stack and crash the process (DoS). Far above any
23
- * legitimate schema nesting.
24
- */
25
- export const MAX_DEPTH = 512;
26
-
27
- const RESERVED_METADATA_KEYS = new Set([
28
- 'title', 'description', 'deprecated', 'deprecatedMessage',
29
- 'notStable', 'since', 'sensitive', 'readonly', 'writeonly', 'examples',
30
- ]);
31
-
32
- /** Sentinel for "value not present" */
33
- export const ABSENT = Symbol.for("anyvali.absent");
34
- export type Absent = typeof ABSENT;
35
-
36
- /**
37
- * Deep-clone a default value so mutable defaults are isolated per parse.
38
- * Uses structuredClone when the value is structured-cloneable; falls back to
39
- * returning the value as-is for things structuredClone cannot handle
40
- * (e.g. functions), which are not portable defaults anyway.
41
- */
42
- function cloneDefault<T>(value: T): T {
43
- if (value === null || typeof value !== "object") return value;
44
- try {
45
- return structuredClone(value);
46
- } catch {
47
- return value;
48
- }
49
- }
50
-
51
- export abstract class BaseSchema<TInput = unknown, TOutput = TInput> {
52
- /** Type-level brand for Infer<T>. Never assigned at runtime. */
53
- declare readonly _output: TOutput;
54
- /** @internal */ _defaultValue: TOutput | Absent = ABSENT;
55
- /** @internal */ _coercionConfig: CoercionConfig | undefined = undefined;
56
- /** @internal */ _isPortable: boolean = true;
57
- /** @internal */ _metadata: Record<string, unknown> | undefined = undefined;
58
-
59
- // ---------- public API ----------
60
-
61
- parse(input: unknown): TOutput {
62
- const result = this.safeParse(input);
63
- if (result.success) return result.data;
64
- throw new ValidationError(result.issues);
65
- }
66
-
67
- safeParse(input: unknown): ParseResult<TOutput> {
68
- const ctx: ParseContext = { path: [], issues: [] };
69
- let output: unknown;
70
- try {
71
- output = this._runPipeline(input, ctx);
72
- } catch (err) {
73
- // Backstop: the depth guard bounds recursion, but if any path resets
74
- // the context and exhausts the stack, convert it to a clean issue so
75
- // safeParse never throws (DoS / contract guarantee).
76
- if (err instanceof RangeError) {
77
- return {
78
- success: false,
79
- issues: [
80
- {
81
- code: ISSUE_CODES.TOO_DEEP,
82
- message: "Maximum validation depth exceeded",
83
- path: [],
84
- expected: "bounded nesting",
85
- received: "too deep",
86
- },
87
- ],
88
- };
89
- }
90
- throw err;
91
- }
92
- if (ctx.issues.length > 0) {
93
- return { success: false, issues: ctx.issues };
94
- }
95
- return { success: true, data: output as TOutput };
96
- }
97
-
98
- /** Internal: run the 5-step pipeline */
99
- _runPipeline(input: unknown, ctx: ParseContext): unknown {
100
- // Depth guard: bound recursion (recursive $ref + deep data) so a crafted
101
- // payload cannot exhaust the call stack. Return a clean issue instead of
102
- // letting safeParse throw a RangeError.
103
- const depth = (ctx.depth ?? 0) + 1;
104
- if (depth > MAX_DEPTH) {
105
- ctx.issues.push({
106
- code: ISSUE_CODES.TOO_DEEP,
107
- message: `Maximum validation depth of ${MAX_DEPTH} exceeded`,
108
- path: [...ctx.path],
109
- expected: `<= ${MAX_DEPTH} levels`,
110
- received: "too deep",
111
- });
112
- return undefined;
113
- }
114
- ctx.depth = depth;
115
- try {
116
- return this._runPipelineInner(input, ctx);
117
- } finally {
118
- ctx.depth = depth - 1;
119
- }
120
- }
121
-
122
- private _runPipelineInner(input: unknown, ctx: ParseContext): unknown {
123
- // Step 1: detect presence
124
- const isAbsent = input === undefined || input === ABSENT;
125
-
126
- let value: unknown = input;
127
-
128
- // Step 2: coercion (only for present values)
129
- if (!isAbsent && this._coercionConfig) {
130
- const coerced = applyCoercion(
131
- value,
132
- this._coercionConfig,
133
- this._getCoercionTarget()
134
- );
135
- if (coerced.success) {
136
- value = coerced.value;
137
- } else {
138
- ctx.issues.push({
139
- code: ISSUE_CODES.COERCION_FAILED,
140
- message: coerced.message,
141
- path: [...ctx.path],
142
- expected: this._getCoercionTarget(),
143
- received: String(input),
144
- });
145
- return undefined;
146
- }
147
- }
148
-
149
- // Step 3: default materialization (only for absent values)
150
- let usedDefault = false;
151
- if (isAbsent && this._defaultValue !== ABSENT) {
152
- // Deep-clone so mutable defaults (arrays/objects) are not shared across
153
- // parses. Pass-through schemas (any/unknown) return the value by
154
- // reference, so without cloning a mutation would corrupt the default.
155
- value = cloneDefault(this._defaultValue);
156
- usedDefault = true;
157
- }
158
-
159
- // Step 4: validate
160
- const issuesBefore = ctx.issues.length;
161
- const result = this._validate(value, ctx);
162
-
163
- // If default was materialized and validation failed, remap issues to default_invalid
164
- if (usedDefault && ctx.issues.length > issuesBefore) {
165
- for (let i = issuesBefore; i < ctx.issues.length; i++) {
166
- const issue = ctx.issues[i];
167
- ctx.issues[i] = {
168
- ...issue,
169
- code: ISSUE_CODES.DEFAULT_INVALID,
170
- };
171
- }
172
- }
173
-
174
- return result;
175
- }
176
-
177
- /** Override in subclasses to provide the coercion target type name */
178
- _getCoercionTarget(): string {
179
- return "unknown";
180
- }
181
-
182
- abstract _validate(input: unknown, ctx: ParseContext): unknown;
183
-
184
- abstract _toNode(): SchemaNode;
185
-
186
- // optional() and nullable() are provided via standalone functions
187
- // to avoid circular imports. See index.ts.
188
-
189
- default(value: TOutput): this {
190
- const clone = this._clone();
191
- clone._defaultValue = value;
192
- return clone;
193
- }
194
-
195
- coerce(options: CoercionConfig = {}): this {
196
- const clone = this._clone();
197
- clone._coercionConfig = { ...options };
198
- return clone;
199
- }
200
-
201
- describe(description: string, opts?: DescribeOptions): this {
202
- const reservedMeta = this._validateDescribeOpts(description, opts);
203
- const clone = this._clone();
204
- clone._metadata = { ...(clone._metadata || {}), ...reservedMeta };
205
- return clone;
206
- }
207
-
208
- metadata(meta: Record<string, unknown>, opts?: MetadataOptions): this {
209
- // Validate no reserved keys
210
- for (const key of Object.keys(meta)) {
211
- if (RESERVED_METADATA_KEYS.has(key)) {
212
- throw new Error(`metadata(): "${key}" is a reserved key. Use describe() instead.`);
213
- }
214
- }
215
- const clone = this._clone();
216
- if (opts?.replace) {
217
- // Replace mode: keep only reserved keys from existing, add new non-reserved
218
- const existing = clone._metadata || {};
219
- const reserved: Record<string, unknown> = {};
220
- for (const [k, v] of Object.entries(existing)) {
221
- if (RESERVED_METADATA_KEYS.has(k)) {
222
- reserved[k] = v;
223
- }
224
- }
225
- clone._metadata = { ...reserved, ...meta };
226
- } else {
227
- // Merge mode (default): shallow merge
228
- clone._metadata = { ...(clone._metadata || {}), ...meta };
229
- }
230
- return clone;
231
- }
232
-
233
- export(mode: ExportMode = "portable"): AnyValiDocument {
234
- if (mode === "portable" && !this._isPortable) {
235
- throw new Error(
236
- "Cannot export in portable mode: schema contains non-portable features"
237
- );
238
- }
239
- const node = this._toNode();
240
- return {
241
- anyvaliVersion: ANYVALI_VERSION,
242
- schemaVersion: SCHEMA_VERSION,
243
- root: node,
244
- definitions: {},
245
- extensions: {},
246
- };
247
- }
248
-
249
- // ---------- internal helpers ----------
250
-
251
- private _validateDescribeOpts(description: string, opts?: DescribeOptions): Record<string, unknown> {
252
- const meta: Record<string, unknown> = { description };
253
-
254
- if (typeof description !== 'string') {
255
- throw new Error('describe(): description must be a string');
256
- }
257
-
258
- if (opts) {
259
- if (opts.title !== undefined) {
260
- if (typeof opts.title !== 'string') throw new Error('describe(): title must be a string');
261
- meta.title = opts.title;
262
- }
263
- if (opts.deprecated !== undefined) {
264
- if (typeof opts.deprecated !== 'boolean') throw new Error('describe(): deprecated must be a boolean');
265
- meta.deprecated = opts.deprecated;
266
- }
267
- if (opts.deprecatedMessage !== undefined) {
268
- if (typeof opts.deprecatedMessage !== 'string') throw new Error('describe(): deprecatedMessage must be a string');
269
- if (!opts.deprecated) throw new Error('describe(): deprecatedMessage requires deprecated to be true');
270
- meta.deprecatedMessage = opts.deprecatedMessage;
271
- }
272
- if (opts.notStable !== undefined) {
273
- if (typeof opts.notStable !== 'boolean') throw new Error('describe(): notStable must be a boolean');
274
- meta.notStable = opts.notStable;
275
- }
276
- if (opts.since !== undefined) {
277
- if (typeof opts.since !== 'string') throw new Error('describe(): since must be a string');
278
- meta.since = opts.since;
279
- }
280
- if (opts.sensitive !== undefined) {
281
- if (typeof opts.sensitive !== 'boolean') throw new Error('describe(): sensitive must be a boolean');
282
- meta.sensitive = opts.sensitive;
283
- }
284
- if (opts.readonly !== undefined) {
285
- if (typeof opts.readonly !== 'boolean') throw new Error('describe(): readonly must be a boolean');
286
- meta.readonly = opts.readonly;
287
- }
288
- if (opts.writeonly !== undefined) {
289
- if (typeof opts.writeonly !== 'boolean') throw new Error('describe(): writeonly must be a boolean');
290
- meta.writeonly = opts.writeonly;
291
- }
292
- if (opts.readonly && opts.writeonly) {
293
- throw new Error('describe(): readonly and writeonly cannot both be true');
294
- }
295
- if (opts.examples !== undefined) {
296
- if (!Array.isArray(opts.examples)) throw new Error('describe(): examples must be an array');
297
- meta.examples = opts.examples;
298
- }
299
- }
300
-
301
- return meta;
302
- }
303
-
304
- protected _clone(): this {
305
- const clone = Object.create(Object.getPrototypeOf(this));
306
- Object.assign(clone, this);
307
- return clone as this;
308
- }
309
-
310
- protected _addDefault(node: SchemaNode): SchemaNode {
311
- if (this._defaultValue !== ABSENT) {
312
- node.default = this._defaultValue;
313
- }
314
- if (this._coercionConfig) {
315
- node.coerce = { ...this._coercionConfig };
316
- }
317
- if (this._metadata && Object.keys(this._metadata).length > 0) {
318
- node.metadata = { ...this._metadata };
319
- }
320
- return node;
321
- }
322
- }
1
+ import type {
2
+ ParseResult,
3
+ ValidationIssue,
4
+ ParseContext,
5
+ SchemaNode,
6
+ AnyValiDocument,
7
+ ExportMode,
8
+ CoercionConfig,
9
+ MetadataOptions,
10
+ DescribeOptions,
11
+ } from "../types.js";
12
+ import { ValidationError } from "../errors.js";
13
+ import { applyCoercion } from "../parse/coerce.js";
14
+ import { ISSUE_CODES } from "../issue-codes.js";
15
+
16
+ const ANYVALI_VERSION = "1.0";
17
+ const SCHEMA_VERSION = "1.1";
18
+
19
+ /**
20
+ * Maximum validation recursion depth. Bounds recursion through recursive
21
+ * `$ref` schemas (and deeply nested data) so a malicious payload cannot
22
+ * exhaust the call stack and crash the process (DoS). Far above any
23
+ * legitimate schema nesting.
24
+ */
25
+ export const MAX_DEPTH = 512;
26
+
27
+ const RESERVED_METADATA_KEYS = new Set([
28
+ 'title', 'description', 'deprecated', 'deprecatedMessage',
29
+ 'notStable', 'since', 'sensitive', 'readonly', 'writeonly', 'examples',
30
+ ]);
31
+
32
+ /** Sentinel for "value not present" */
33
+ export const ABSENT = Symbol.for("anyvali.absent");
34
+ export type Absent = typeof ABSENT;
35
+
36
+ /**
37
+ * Deep-clone a default value so mutable defaults are isolated per parse.
38
+ * Uses structuredClone when the value is structured-cloneable; falls back to
39
+ * returning the value as-is for things structuredClone cannot handle
40
+ * (e.g. functions), which are not portable defaults anyway.
41
+ */
42
+ function cloneDefault<T>(value: T): T {
43
+ if (value === null || typeof value !== "object") return value;
44
+ try {
45
+ return structuredClone(value);
46
+ } catch {
47
+ return value;
48
+ }
49
+ }
50
+
51
+ export abstract class BaseSchema<TInput = unknown, TOutput = TInput> {
52
+ /** Type-level brand for Infer<T>. Never assigned at runtime. */
53
+ declare readonly _output: TOutput;
54
+ /** @internal */ _defaultValue: TOutput | Absent = ABSENT;
55
+ /** @internal */ _coercionConfig: CoercionConfig | undefined = undefined;
56
+ /** @internal */ _isPortable: boolean = true;
57
+ /** @internal */ _metadata: Record<string, unknown> | undefined = undefined;
58
+
59
+ // ---------- public API ----------
60
+
61
+ parse(input: unknown): TOutput {
62
+ const result = this.safeParse(input);
63
+ if (result.success) return result.data;
64
+ throw new ValidationError(result.issues);
65
+ }
66
+
67
+ safeParse(input: unknown): ParseResult<TOutput> {
68
+ const ctx: ParseContext = { path: [], issues: [] };
69
+ let output: unknown;
70
+ try {
71
+ output = this._runPipeline(input, ctx);
72
+ } catch (err) {
73
+ // Backstop: the depth guard bounds recursion, but if any path resets
74
+ // the context and exhausts the stack, convert it to a clean issue so
75
+ // safeParse never throws (DoS / contract guarantee).
76
+ if (err instanceof RangeError) {
77
+ return {
78
+ success: false,
79
+ issues: [
80
+ {
81
+ code: ISSUE_CODES.TOO_DEEP,
82
+ message: "Maximum validation depth exceeded",
83
+ path: [],
84
+ expected: "bounded nesting",
85
+ received: "too deep",
86
+ },
87
+ ],
88
+ };
89
+ }
90
+ throw err;
91
+ }
92
+ if (ctx.issues.length > 0) {
93
+ return { success: false, issues: ctx.issues };
94
+ }
95
+ return { success: true, data: output as TOutput };
96
+ }
97
+
98
+ /** Internal: run the 5-step pipeline */
99
+ _runPipeline(input: unknown, ctx: ParseContext): unknown {
100
+ // Depth guard: bound recursion (recursive $ref + deep data) so a crafted
101
+ // payload cannot exhaust the call stack. Return a clean issue instead of
102
+ // letting safeParse throw a RangeError.
103
+ const depth = (ctx.depth ?? 0) + 1;
104
+ if (depth > MAX_DEPTH) {
105
+ ctx.issues.push({
106
+ code: ISSUE_CODES.TOO_DEEP,
107
+ message: `Maximum validation depth of ${MAX_DEPTH} exceeded`,
108
+ path: [...ctx.path],
109
+ expected: `<= ${MAX_DEPTH} levels`,
110
+ received: "too deep",
111
+ });
112
+ return undefined;
113
+ }
114
+ ctx.depth = depth;
115
+ try {
116
+ return this._runPipelineInner(input, ctx);
117
+ } finally {
118
+ ctx.depth = depth - 1;
119
+ }
120
+ }
121
+
122
+ private _runPipelineInner(input: unknown, ctx: ParseContext): unknown {
123
+ // Step 1: detect presence
124
+ const isAbsent = input === undefined || input === ABSENT;
125
+
126
+ let value: unknown = input;
127
+
128
+ // Step 2: coercion (only for present values)
129
+ if (!isAbsent && this._coercionConfig) {
130
+ const coerced = applyCoercion(
131
+ value,
132
+ this._coercionConfig,
133
+ this._getCoercionTarget()
134
+ );
135
+ if (coerced.success) {
136
+ value = coerced.value;
137
+ } else {
138
+ ctx.issues.push({
139
+ code: ISSUE_CODES.COERCION_FAILED,
140
+ message: coerced.message,
141
+ path: [...ctx.path],
142
+ expected: this._getCoercionTarget(),
143
+ received: String(input),
144
+ });
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ // Step 3: default materialization (only for absent values)
150
+ let usedDefault = false;
151
+ if (isAbsent && this._defaultValue !== ABSENT) {
152
+ // Deep-clone so mutable defaults (arrays/objects) are not shared across
153
+ // parses. Pass-through schemas (any/unknown) return the value by
154
+ // reference, so without cloning a mutation would corrupt the default.
155
+ value = cloneDefault(this._defaultValue);
156
+ usedDefault = true;
157
+ }
158
+
159
+ // Step 4: validate
160
+ const issuesBefore = ctx.issues.length;
161
+ const result = this._validate(value, ctx);
162
+
163
+ // If default was materialized and validation failed, remap issues to default_invalid
164
+ if (usedDefault && ctx.issues.length > issuesBefore) {
165
+ for (let i = issuesBefore; i < ctx.issues.length; i++) {
166
+ const issue = ctx.issues[i];
167
+ ctx.issues[i] = {
168
+ ...issue,
169
+ code: ISSUE_CODES.DEFAULT_INVALID,
170
+ };
171
+ }
172
+ }
173
+
174
+ return result;
175
+ }
176
+
177
+ /** Override in subclasses to provide the coercion target type name */
178
+ _getCoercionTarget(): string {
179
+ return "unknown";
180
+ }
181
+
182
+ abstract _validate(input: unknown, ctx: ParseContext): unknown;
183
+
184
+ abstract _toNode(): SchemaNode;
185
+
186
+ // optional() and nullable() are provided via standalone functions
187
+ // to avoid circular imports. See index.ts.
188
+
189
+ default(value: TOutput): this {
190
+ const clone = this._clone();
191
+ clone._defaultValue = value;
192
+ return clone;
193
+ }
194
+
195
+ coerce(options: CoercionConfig = {}): this {
196
+ const clone = this._clone();
197
+ clone._coercionConfig = { ...options };
198
+ return clone;
199
+ }
200
+
201
+ describe(description: string, opts?: DescribeOptions): this {
202
+ const reservedMeta = this._validateDescribeOpts(description, opts);
203
+ const clone = this._clone();
204
+ clone._metadata = { ...(clone._metadata || {}), ...reservedMeta };
205
+ return clone;
206
+ }
207
+
208
+ metadata(meta: Record<string, unknown>, opts?: MetadataOptions): this {
209
+ // Validate no reserved keys
210
+ for (const key of Object.keys(meta)) {
211
+ if (RESERVED_METADATA_KEYS.has(key)) {
212
+ throw new Error(`metadata(): "${key}" is a reserved key. Use describe() instead.`);
213
+ }
214
+ }
215
+ const clone = this._clone();
216
+ if (opts?.replace) {
217
+ // Replace mode: keep only reserved keys from existing, add new non-reserved
218
+ const existing = clone._metadata || {};
219
+ const reserved: Record<string, unknown> = {};
220
+ for (const [k, v] of Object.entries(existing)) {
221
+ if (RESERVED_METADATA_KEYS.has(k)) {
222
+ reserved[k] = v;
223
+ }
224
+ }
225
+ clone._metadata = { ...reserved, ...meta };
226
+ } else {
227
+ // Merge mode (default): shallow merge
228
+ clone._metadata = { ...(clone._metadata || {}), ...meta };
229
+ }
230
+ return clone;
231
+ }
232
+
233
+ export(mode: ExportMode = "portable"): AnyValiDocument {
234
+ if (mode === "portable" && !this._isPortable) {
235
+ throw new Error(
236
+ "Cannot export in portable mode: schema contains non-portable features"
237
+ );
238
+ }
239
+ const node = this._toNode();
240
+ return {
241
+ anyvaliVersion: ANYVALI_VERSION,
242
+ schemaVersion: SCHEMA_VERSION,
243
+ root: node,
244
+ definitions: {},
245
+ extensions: {},
246
+ };
247
+ }
248
+
249
+ // ---------- internal helpers ----------
250
+
251
+ private _validateDescribeOpts(description: string, opts?: DescribeOptions): Record<string, unknown> {
252
+ const meta: Record<string, unknown> = { description };
253
+
254
+ if (typeof description !== 'string') {
255
+ throw new Error('describe(): description must be a string');
256
+ }
257
+
258
+ if (opts) {
259
+ if (opts.title !== undefined) {
260
+ if (typeof opts.title !== 'string') throw new Error('describe(): title must be a string');
261
+ meta.title = opts.title;
262
+ }
263
+ if (opts.deprecated !== undefined) {
264
+ if (typeof opts.deprecated !== 'boolean') throw new Error('describe(): deprecated must be a boolean');
265
+ meta.deprecated = opts.deprecated;
266
+ }
267
+ if (opts.deprecatedMessage !== undefined) {
268
+ if (typeof opts.deprecatedMessage !== 'string') throw new Error('describe(): deprecatedMessage must be a string');
269
+ if (!opts.deprecated) throw new Error('describe(): deprecatedMessage requires deprecated to be true');
270
+ meta.deprecatedMessage = opts.deprecatedMessage;
271
+ }
272
+ if (opts.notStable !== undefined) {
273
+ if (typeof opts.notStable !== 'boolean') throw new Error('describe(): notStable must be a boolean');
274
+ meta.notStable = opts.notStable;
275
+ }
276
+ if (opts.since !== undefined) {
277
+ if (typeof opts.since !== 'string') throw new Error('describe(): since must be a string');
278
+ meta.since = opts.since;
279
+ }
280
+ if (opts.sensitive !== undefined) {
281
+ if (typeof opts.sensitive !== 'boolean') throw new Error('describe(): sensitive must be a boolean');
282
+ meta.sensitive = opts.sensitive;
283
+ }
284
+ if (opts.readonly !== undefined) {
285
+ if (typeof opts.readonly !== 'boolean') throw new Error('describe(): readonly must be a boolean');
286
+ meta.readonly = opts.readonly;
287
+ }
288
+ if (opts.writeonly !== undefined) {
289
+ if (typeof opts.writeonly !== 'boolean') throw new Error('describe(): writeonly must be a boolean');
290
+ meta.writeonly = opts.writeonly;
291
+ }
292
+ if (opts.readonly && opts.writeonly) {
293
+ throw new Error('describe(): readonly and writeonly cannot both be true');
294
+ }
295
+ if (opts.examples !== undefined) {
296
+ if (!Array.isArray(opts.examples)) throw new Error('describe(): examples must be an array');
297
+ meta.examples = opts.examples;
298
+ }
299
+ }
300
+
301
+ return meta;
302
+ }
303
+
304
+ protected _clone(): this {
305
+ const clone = Object.create(Object.getPrototypeOf(this));
306
+ Object.assign(clone, this);
307
+ return clone as this;
308
+ }
309
+
310
+ protected _addDefault(node: SchemaNode): SchemaNode {
311
+ if (this._defaultValue !== ABSENT) {
312
+ node.default = this._defaultValue;
313
+ }
314
+ if (this._coercionConfig) {
315
+ node.coerce = { ...this._coercionConfig };
316
+ }
317
+ if (this._metadata && Object.keys(this._metadata).length > 0) {
318
+ node.metadata = { ...this._metadata };
319
+ }
320
+ return node;
321
+ }
322
+ }