@forgehive/schema 0.1.3 → 0.2.0
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/README.md +207 -0
- package/dist/index.d.ts +79 -71
- package/dist/index.js +75 -182
- package/dist/test/custom-validations.test.js +45 -131
- package/dist/test/dates.test.js +41 -21
- package/dist/test/describe.test.d.ts +1 -0
- package/dist/test/describe.test.js +172 -0
- package/dist/test/from.test.d.ts +1 -0
- package/dist/test/from.test.js +162 -0
- package/dist/test/index.test.js +130 -30
- package/dist/test/optionals.test.js +22 -18
- package/dist/test/parse.test.d.ts +1 -0
- package/dist/test/parse.test.js +134 -0
- package/dist/test/record.test.js +47 -62
- package/package.json +2 -2
- package/src/index.ts +112 -250
- package/src/test/custom-validations.test.ts +45 -132
- package/src/test/dates.test.ts +45 -22
- package/src/test/describe.test.ts +190 -0
- package/src/test/from.test.ts +191 -0
- package/src/test/index.test.ts +148 -31
- package/src/test/optionals.test.ts +23 -18
- package/src/test/parse.test.ts +160 -0
- package/src/test/record.test.ts +48 -72
- package/tsconfig.json +2 -3
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const index_1 = require("../index");
|
|
4
|
-
describe('Schema
|
|
4
|
+
describe('Schema Optional Fields', () => {
|
|
5
5
|
describe('Optional Fields', () => {
|
|
6
6
|
it('should handle optional string fields', () => {
|
|
7
|
-
const schema = index_1.Schema
|
|
8
|
-
name:
|
|
9
|
-
age:
|
|
7
|
+
const schema = new index_1.Schema({
|
|
8
|
+
name: index_1.Schema.string().optional(),
|
|
9
|
+
age: index_1.Schema.number()
|
|
10
10
|
});
|
|
11
11
|
// Valid with optional field present
|
|
12
12
|
expect(schema.validate({ name: 'John', age: 30 })).toBe(true);
|
|
@@ -16,9 +16,9 @@ describe('Schema Custom Validations', () => {
|
|
|
16
16
|
expect(schema.validate({ name: 'John' })).toBe(false);
|
|
17
17
|
});
|
|
18
18
|
it('should handle optional array fields', () => {
|
|
19
|
-
const schema = index_1.Schema
|
|
20
|
-
tags:
|
|
21
|
-
scores:
|
|
19
|
+
const schema = new index_1.Schema({
|
|
20
|
+
tags: index_1.Schema.array(index_1.Schema.string()).optional(),
|
|
21
|
+
scores: index_1.Schema.array(index_1.Schema.number())
|
|
22
22
|
});
|
|
23
23
|
// Valid with optional array present
|
|
24
24
|
expect(schema.validate({ tags: ['tag1', 'tag2'], scores: [1, 2, 3] })).toBe(true);
|
|
@@ -28,23 +28,25 @@ describe('Schema Custom Validations', () => {
|
|
|
28
28
|
expect(schema.validate({ tags: ['tag1'] })).toBe(false);
|
|
29
29
|
});
|
|
30
30
|
it('should correctly describe optional fields', () => {
|
|
31
|
-
const schema = index_1.Schema
|
|
32
|
-
name:
|
|
33
|
-
age:
|
|
34
|
-
tags:
|
|
31
|
+
const schema = new index_1.Schema({
|
|
32
|
+
name: index_1.Schema.string().optional(),
|
|
33
|
+
age: index_1.Schema.number(),
|
|
34
|
+
tags: index_1.Schema.array(index_1.Schema.string()).optional()
|
|
35
35
|
});
|
|
36
36
|
const description = schema.describe();
|
|
37
|
-
|
|
38
|
-
expect(description.
|
|
39
|
-
expect(description.
|
|
37
|
+
// Optionality is expressed by absence from the `required` list
|
|
38
|
+
expect(description.required).toEqual(['age']);
|
|
39
|
+
expect(description.properties?.name).toEqual({ type: 'string' });
|
|
40
|
+
expect(description.properties?.age).toEqual({ type: 'number' });
|
|
41
|
+
expect(description.properties?.tags).toEqual({ type: 'array', items: { type: 'string' } });
|
|
40
42
|
});
|
|
41
43
|
});
|
|
42
44
|
describe('Schema Roundtrip', () => {
|
|
43
45
|
it('should maintain optional fields through describe/from cycle', () => {
|
|
44
|
-
const originalSchema = index_1.Schema
|
|
45
|
-
name:
|
|
46
|
-
age:
|
|
47
|
-
tags:
|
|
46
|
+
const originalSchema = new index_1.Schema({
|
|
47
|
+
name: index_1.Schema.string().optional(),
|
|
48
|
+
age: index_1.Schema.number(),
|
|
49
|
+
tags: index_1.Schema.array(index_1.Schema.string()).optional()
|
|
48
50
|
});
|
|
49
51
|
const description = originalSchema.describe();
|
|
50
52
|
const reconstructedSchema = index_1.Schema.from(description);
|
|
@@ -55,6 +57,8 @@ describe('Schema Custom Validations', () => {
|
|
|
55
57
|
expect(reconstructedSchema.validate(validData)).toBe(true);
|
|
56
58
|
expect(originalSchema.validate(dataWithoutOptional)).toBe(true);
|
|
57
59
|
expect(reconstructedSchema.validate(dataWithoutOptional)).toBe(true);
|
|
60
|
+
// The reconstructed schema preserves which field stayed required
|
|
61
|
+
expect(reconstructedSchema.describe().required).toEqual(['age']);
|
|
58
62
|
});
|
|
59
63
|
});
|
|
60
64
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_1 = require("../index");
|
|
4
|
+
describe('Schema.parse()', () => {
|
|
5
|
+
it('returns typed data on success', () => {
|
|
6
|
+
const schema = new index_1.Schema({
|
|
7
|
+
name: index_1.Schema.string(),
|
|
8
|
+
age: index_1.Schema.number(),
|
|
9
|
+
});
|
|
10
|
+
const data = schema.parse({ name: 'John', age: 30 });
|
|
11
|
+
expect(data).toEqual({ name: 'John', age: 30 });
|
|
12
|
+
// Type-level: the result is { name: string; age: number }
|
|
13
|
+
const name = data.name;
|
|
14
|
+
const age = data.age;
|
|
15
|
+
expect(name).toBe('John');
|
|
16
|
+
expect(age).toBe(30);
|
|
17
|
+
});
|
|
18
|
+
it('strips unknown keys', () => {
|
|
19
|
+
const schema = new index_1.Schema({
|
|
20
|
+
name: index_1.Schema.string(),
|
|
21
|
+
});
|
|
22
|
+
const data = schema.parse({ name: 'John', extra: 'ignored' });
|
|
23
|
+
expect(data).toEqual({ name: 'John' });
|
|
24
|
+
expect(data).not.toHaveProperty('extra');
|
|
25
|
+
});
|
|
26
|
+
it('coerces nothing and throws ZodError on invalid data', () => {
|
|
27
|
+
const schema = new index_1.Schema({
|
|
28
|
+
name: index_1.Schema.string(),
|
|
29
|
+
age: index_1.Schema.number(),
|
|
30
|
+
});
|
|
31
|
+
expect(() => schema.parse({ name: 'John', age: 'thirty' })).toThrow();
|
|
32
|
+
});
|
|
33
|
+
it('throws a ZodError exposing issues with path and message', () => {
|
|
34
|
+
const schema = new index_1.Schema({
|
|
35
|
+
name: index_1.Schema.string(),
|
|
36
|
+
age: index_1.Schema.number().min(18),
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
schema.parse({ name: 123, age: 10 });
|
|
40
|
+
throw new Error('expected parse to throw');
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const zodError = err;
|
|
44
|
+
expect(Array.isArray(zodError.issues)).toBe(true);
|
|
45
|
+
const paths = (zodError.issues ?? []).map((issue) => issue.path.join('.'));
|
|
46
|
+
expect(paths).toContain('name');
|
|
47
|
+
expect(paths).toContain('age');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it('parses nested objects and optionals', () => {
|
|
51
|
+
const schema = new index_1.Schema({
|
|
52
|
+
user: index_1.Schema.object({
|
|
53
|
+
name: index_1.Schema.string(),
|
|
54
|
+
nickname: index_1.Schema.string().optional(),
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
expect(schema.parse({ user: { name: 'John' } })).toEqual({ user: { name: 'John' } });
|
|
58
|
+
expect(schema.parse({ user: { name: 'John', nickname: 'JJ' } })).toEqual({
|
|
59
|
+
user: { name: 'John', nickname: 'JJ' },
|
|
60
|
+
});
|
|
61
|
+
expect(() => schema.parse({ user: {} })).toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('Schema.safeParse()', () => {
|
|
65
|
+
it('returns success: true with data on valid input', () => {
|
|
66
|
+
const schema = new index_1.Schema({
|
|
67
|
+
name: index_1.Schema.string(),
|
|
68
|
+
age: index_1.Schema.number(),
|
|
69
|
+
});
|
|
70
|
+
const result = schema.safeParse({ name: 'John', age: 30 });
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
if (result.success) {
|
|
73
|
+
expect(result.data).toEqual({ name: 'John', age: 30 });
|
|
74
|
+
// Type-level: result.data is fully typed
|
|
75
|
+
const name = result.data.name;
|
|
76
|
+
expect(name).toBe('John');
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
it('returns success: false with an error on invalid input', () => {
|
|
80
|
+
const schema = new index_1.Schema({
|
|
81
|
+
name: index_1.Schema.string(),
|
|
82
|
+
age: index_1.Schema.number(),
|
|
83
|
+
});
|
|
84
|
+
const result = schema.safeParse({ name: 'John', age: 'thirty' });
|
|
85
|
+
expect(result.success).toBe(false);
|
|
86
|
+
if (!result.success) {
|
|
87
|
+
expect(result.error).toBeDefined();
|
|
88
|
+
expect(Array.isArray(result.error.issues)).toBe(true);
|
|
89
|
+
const paths = result.error.issues.map((issue) => issue.path.join('.'));
|
|
90
|
+
expect(paths).toContain('age');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
it('does not throw on invalid input', () => {
|
|
94
|
+
const schema = new index_1.Schema({
|
|
95
|
+
age: index_1.Schema.number(),
|
|
96
|
+
});
|
|
97
|
+
expect(() => schema.safeParse({ age: 'nope' })).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
it('reports each failing field in the issues list', () => {
|
|
100
|
+
const schema = new index_1.Schema({
|
|
101
|
+
email: index_1.Schema.email(),
|
|
102
|
+
age: index_1.Schema.number().min(18),
|
|
103
|
+
username: index_1.Schema.string().min(3),
|
|
104
|
+
});
|
|
105
|
+
const result = schema.safeParse({ email: 'nope', age: 5, username: 'ab' });
|
|
106
|
+
expect(result.success).toBe(false);
|
|
107
|
+
if (!result.success) {
|
|
108
|
+
const paths = result.error.issues.map((issue) => issue.path.join('.'));
|
|
109
|
+
expect(paths).toEqual(expect.arrayContaining(['email', 'age', 'username']));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
it('strips unknown keys on success', () => {
|
|
113
|
+
const schema = new index_1.Schema({
|
|
114
|
+
name: index_1.Schema.string(),
|
|
115
|
+
});
|
|
116
|
+
const result = schema.safeParse({ name: 'John', extra: true });
|
|
117
|
+
expect(result.success).toBe(true);
|
|
118
|
+
if (result.success) {
|
|
119
|
+
expect(result.data).toEqual({ name: 'John' });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
it('infers optional fields as possibly undefined', () => {
|
|
123
|
+
const schema = new index_1.Schema({
|
|
124
|
+
name: index_1.Schema.string(),
|
|
125
|
+
age: index_1.Schema.number().optional(),
|
|
126
|
+
});
|
|
127
|
+
const result = schema.safeParse({ name: 'John' });
|
|
128
|
+
expect(result.success).toBe(true);
|
|
129
|
+
if (result.success) {
|
|
130
|
+
const age = result.data.age;
|
|
131
|
+
expect(age).toBeUndefined();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
package/dist/test/record.test.js
CHANGED
|
@@ -32,22 +32,17 @@ describe('Schema Record Types', () => {
|
|
|
32
32
|
});
|
|
33
33
|
expect(result).toBe(false);
|
|
34
34
|
});
|
|
35
|
-
it('should
|
|
36
|
-
const
|
|
37
|
-
data:
|
|
38
|
-
|
|
39
|
-
optional: false
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
const schema = index_1.default.from(description);
|
|
35
|
+
it('should round-trip a string record schema', () => {
|
|
36
|
+
const schema = index_1.default.from(new index_1.default({
|
|
37
|
+
data: index_1.default.stringRecord(),
|
|
38
|
+
}).describe());
|
|
43
39
|
const validData = {
|
|
44
40
|
data: {
|
|
45
41
|
firstName: 'John',
|
|
46
42
|
lastName: 'Doe'
|
|
47
43
|
}
|
|
48
44
|
};
|
|
49
|
-
|
|
50
|
-
expect(result).toBe(true);
|
|
45
|
+
expect(schema.validate(validData)).toBe(true);
|
|
51
46
|
});
|
|
52
47
|
});
|
|
53
48
|
describe('Number Record', () => {
|
|
@@ -77,22 +72,17 @@ describe('Schema Record Types', () => {
|
|
|
77
72
|
});
|
|
78
73
|
expect(result).toBe(false);
|
|
79
74
|
});
|
|
80
|
-
it('should
|
|
81
|
-
const
|
|
82
|
-
data:
|
|
83
|
-
|
|
84
|
-
optional: false
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
const schema = index_1.default.from(description);
|
|
75
|
+
it('should round-trip a number record schema', () => {
|
|
76
|
+
const schema = index_1.default.from(new index_1.default({
|
|
77
|
+
data: index_1.default.numberRecord(),
|
|
78
|
+
}).describe());
|
|
88
79
|
const validData = {
|
|
89
80
|
data: {
|
|
90
81
|
age: 30,
|
|
91
82
|
experience: 5
|
|
92
83
|
}
|
|
93
84
|
};
|
|
94
|
-
|
|
95
|
-
expect(result).toBe(true);
|
|
85
|
+
expect(schema.validate(validData)).toBe(true);
|
|
96
86
|
});
|
|
97
87
|
});
|
|
98
88
|
describe('Boolean Record', () => {
|
|
@@ -122,22 +112,17 @@ describe('Schema Record Types', () => {
|
|
|
122
112
|
});
|
|
123
113
|
expect(result).toBe(false);
|
|
124
114
|
});
|
|
125
|
-
it('should
|
|
126
|
-
const
|
|
127
|
-
data:
|
|
128
|
-
|
|
129
|
-
optional: false
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
const schema = index_1.default.from(description);
|
|
115
|
+
it('should round-trip a boolean record schema', () => {
|
|
116
|
+
const schema = index_1.default.from(new index_1.default({
|
|
117
|
+
data: index_1.default.booleanRecord(),
|
|
118
|
+
}).describe());
|
|
133
119
|
const validData = {
|
|
134
120
|
data: {
|
|
135
121
|
isActive: true,
|
|
136
122
|
isAdmin: false
|
|
137
123
|
}
|
|
138
124
|
};
|
|
139
|
-
|
|
140
|
-
expect(result).toBe(true);
|
|
125
|
+
expect(schema.validate(validData)).toBe(true);
|
|
141
126
|
});
|
|
142
127
|
});
|
|
143
128
|
describe('Mixed Record', () => {
|
|
@@ -206,14 +191,10 @@ describe('Schema Record Types', () => {
|
|
|
206
191
|
});
|
|
207
192
|
expect(result).toBe(false);
|
|
208
193
|
});
|
|
209
|
-
it('should
|
|
210
|
-
const
|
|
211
|
-
data:
|
|
212
|
-
|
|
213
|
-
optional: false
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
const schema = index_1.default.from(description);
|
|
194
|
+
it('should round-trip a mixed record schema', () => {
|
|
195
|
+
const schema = index_1.default.from(new index_1.default({
|
|
196
|
+
data: index_1.default.mixedRecord(),
|
|
197
|
+
}).describe());
|
|
217
198
|
const validData = {
|
|
218
199
|
data: {
|
|
219
200
|
name: 'John',
|
|
@@ -221,12 +202,11 @@ describe('Schema Record Types', () => {
|
|
|
221
202
|
isActive: true
|
|
222
203
|
}
|
|
223
204
|
};
|
|
224
|
-
|
|
225
|
-
expect(result).toBe(true);
|
|
205
|
+
expect(schema.validate(validData)).toBe(true);
|
|
226
206
|
});
|
|
227
207
|
});
|
|
228
208
|
describe('Common Record Functionality', () => {
|
|
229
|
-
it('should
|
|
209
|
+
it('should treat numeric keys as strings (JS limitation)', () => {
|
|
230
210
|
const schema = new index_1.default({
|
|
231
211
|
data: index_1.default.stringRecord(),
|
|
232
212
|
});
|
|
@@ -247,14 +227,10 @@ describe('Schema Record Types', () => {
|
|
|
247
227
|
const result = schema.validate(invalidData);
|
|
248
228
|
expect(result).toBe(true);
|
|
249
229
|
});
|
|
250
|
-
it('should
|
|
251
|
-
const
|
|
252
|
-
data:
|
|
253
|
-
|
|
254
|
-
optional: true
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
const schema = index_1.default.from(description);
|
|
230
|
+
it('should round-trip an optional record schema', () => {
|
|
231
|
+
const schema = index_1.default.from(new index_1.default({
|
|
232
|
+
data: index_1.default.stringRecord().optional()
|
|
233
|
+
}).describe());
|
|
258
234
|
// Test with record present
|
|
259
235
|
const validData1 = {
|
|
260
236
|
data: {
|
|
@@ -266,38 +242,47 @@ describe('Schema Record Types', () => {
|
|
|
266
242
|
expect(schema.validate(validData1)).toBe(true);
|
|
267
243
|
expect(schema.validate(validData2)).toBe(true);
|
|
268
244
|
});
|
|
269
|
-
it('should describe a
|
|
245
|
+
it('should describe a string record as an object with string additionalProperties', () => {
|
|
270
246
|
const schema = new index_1.default({
|
|
271
247
|
data: index_1.default.stringRecord(),
|
|
272
248
|
});
|
|
273
249
|
const description = schema.describe();
|
|
274
|
-
expect(description).
|
|
275
|
-
|
|
276
|
-
|
|
250
|
+
expect(description.properties?.data).toMatchObject({
|
|
251
|
+
type: 'object',
|
|
252
|
+
additionalProperties: { type: 'string' }
|
|
253
|
+
});
|
|
277
254
|
});
|
|
278
|
-
it('should describe a
|
|
255
|
+
it('should describe a number record as an object with number additionalProperties', () => {
|
|
279
256
|
const schema = new index_1.default({
|
|
280
257
|
data: index_1.default.numberRecord(),
|
|
281
258
|
});
|
|
282
259
|
const description = schema.describe();
|
|
283
|
-
expect(description).
|
|
284
|
-
|
|
260
|
+
expect(description.properties?.data).toMatchObject({
|
|
261
|
+
type: 'object',
|
|
262
|
+
additionalProperties: { type: 'number' }
|
|
263
|
+
});
|
|
285
264
|
});
|
|
286
|
-
it('should describe a
|
|
265
|
+
it('should describe a boolean record as an object with boolean additionalProperties', () => {
|
|
287
266
|
const schema = new index_1.default({
|
|
288
267
|
data: index_1.default.booleanRecord(),
|
|
289
268
|
});
|
|
290
269
|
const description = schema.describe();
|
|
291
|
-
expect(description).
|
|
292
|
-
|
|
270
|
+
expect(description.properties?.data).toMatchObject({
|
|
271
|
+
type: 'object',
|
|
272
|
+
additionalProperties: { type: 'boolean' }
|
|
273
|
+
});
|
|
293
274
|
});
|
|
294
|
-
it('should describe a
|
|
275
|
+
it('should describe a mixed record as an object with anyOf additionalProperties', () => {
|
|
295
276
|
const schema = new index_1.default({
|
|
296
277
|
data: index_1.default.mixedRecord(),
|
|
297
278
|
});
|
|
298
279
|
const description = schema.describe();
|
|
299
|
-
expect(description).
|
|
300
|
-
|
|
280
|
+
expect(description.properties?.data).toMatchObject({
|
|
281
|
+
type: 'object',
|
|
282
|
+
additionalProperties: {
|
|
283
|
+
anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }]
|
|
284
|
+
}
|
|
285
|
+
});
|
|
301
286
|
});
|
|
302
287
|
});
|
|
303
288
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgehive/schema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"ts-jest": "^29.1.2"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"zod": "^
|
|
13
|
+
"zod": "^4.4.3"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc",
|