1import { assert, describe, it } from "@effect/vitest"
2import { Brand, Schema, SchemaRepresentation } from "effect"
3import { throws } from "../../utils/assert.ts"
4
5const filterId = "acme/schema/minLength"
6
7const minLengthReviver: SchemaRepresentation.FilterReviver<{ readonly minimum: number }> = {
8 id: filterId,
9 payloadSchema: Schema.Struct({ minimum: Schema.Number }),
10 revive: ({ annotations, payload }) => minLengthCheck(payload.minimum, annotations)
11}
12
13function minLengthCheck(minimum: number, annotations?: Schema.Annotations.Filter) {
14 return Schema.makeFilter<string>((value) => value.length >= minimum, {
15 representation: { id: filterId, payload: { minimum } },
16 ...annotations
17 })
18}
19
20function revive(
21 schema: Schema.Top,
22 revivers: ReadonlyArray<SchemaRepresentation.AnyReviver> = []
23): Schema.Top {
24 return SchemaRepresentation.fromRepresentation(
25 SchemaRepresentation.fromJson(SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast))),
26 { revivers }
27 )
28}
29
30function assertRepresentationRoundtrip(
31 schema: Schema.Top,
32 revivers: ReadonlyArray<SchemaRepresentation.AnyReviver> = []
33): Schema.Top {
34 const expected = SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(schema.ast))
35 const revived = SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(expected), { revivers })
36 assert.deepStrictEqual(SchemaRepresentation.toJson(SchemaRepresentation.toRepresentation(revived.ast)), expected)
37 return revived
38}
39
40function errorFrom(run: () => unknown): Error {
41 let result: Error | undefined
42 throws(run, (error: unknown) => {
43 assert.instanceOf(error, Error)
44 result = error
45 return undefined
46 })
47 assert.isDefined(result)
48 return result
49}
50
51function filterJson(): Schema.Json {
52 return SchemaRepresentation.toJson(
53 SchemaRepresentation.toRepresentation(Schema.String.check(minLengthCheck(2)).ast)
54 )
55}
56
57describe("SchemaRepresentation.fromRepresentation", () => {
58 it("revives Null", () => {
59 assertRepresentationRoundtrip(Schema.Null)
60 })
61
62 it("revives Undefined", () => {
63 assertRepresentationRoundtrip(Schema.Undefined)
64 })
65
66 it("revives Void", () => {
67 assertRepresentationRoundtrip(Schema.Void)
68 })
69
70 it("revives Never", () => {
71 assertRepresentationRoundtrip(Schema.Never)
72 })
73
74 it("revives Unknown", () => {
75 assertRepresentationRoundtrip(Schema.Unknown)
76 })
77
78 it("revives Any", () => {
79 assertRepresentationRoundtrip(Schema.Any)
80 })
81
82 it("revives String", () => {
83 assertRepresentationRoundtrip(Schema.String)
84 })
85
86 it("revives Number", () => {
87 assertRepresentationRoundtrip(Schema.Number)
88 })
89
90 it("revives Boolean", () => {
91 assertRepresentationRoundtrip(Schema.Boolean)
92 })
93
94 it("revives BigInt", () => {
95 assertRepresentationRoundtrip(Schema.BigInt)
96 })
97
98 it("revives Symbol", () => {
99 assertRepresentationRoundtrip(Schema.Symbol)
100 })
101
102 it("revives ObjectKeyword", () => {
103 assertRepresentationRoundtrip(Schema.ObjectKeyword)
104 })
105
106 it("revives Literal without changing its type", () => {
107 for (
108 const [literal, differentType] of [
109 ["1", 1n],
110 ["true", true],
111 [1, "1"],
112 [1n, "1"],
113 [true, "true"]
114 ] as const
115 ) {
116 const schema = assertRepresentationRoundtrip(Schema.Literal(literal))
117 assert.isTrue(Schema.is(schema)(literal))
118 assert.isFalse(Schema.is(schema)(differentType))
119 }
120 })
121
122 it("revives UniqueSymbol", () => {
123 const symbol = Symbol.for("acme/schema/symbol")
124 const schema = assertRepresentationRoundtrip(Schema.UniqueSymbol(symbol))
125 assert.isTrue(Schema.is(schema)(symbol))
126 assert.isFalse(Schema.is(schema)(Symbol.for("acme/schema/other")))
127 })
128
129 it("revives Enum", () => {
130 const schema = assertRepresentationRoundtrip(Schema.Enum({ A: "a", One: 1 }))
131 assert.isTrue(Schema.is(schema)("a"))
132 assert.isTrue(Schema.is(schema)(1))
133 assert.isFalse(Schema.is(schema)("other"))
134 })
135
136 it("rejects non-finite numeric Enum values", () => {
137 throws(
138 () =>
139 SchemaRepresentation.fromRepresentation({
140 representation: {
141 _tag: "Enum",
142 enums: [["NumberNaN", Number.NaN]],
143 checks: []
144 },
145 references: {}
146 }, { revivers: [] }),
147 new Error("A numeric enum value must be finite, got NaN")
148 )
149 })
150
151 it("revives TemplateLiteral", () => {
152 const schema = assertRepresentationRoundtrip(Schema.TemplateLiteral(["prefix-", Schema.String]))
153 assert.isTrue(Schema.is(schema)("prefix-value"))
154 assert.isFalse(Schema.is(schema)("value"))
155 })
156
157 it("revives a reference in TemplateLiteral as a concrete part", () => {
158 const schema = assertRepresentationRoundtrip(
159 Schema.TemplateLiteral(["prefix-", Schema.String.annotate({ identifier: "Part" })])
160 )
161 assert.strictEqual(schema.ast._tag, "TemplateLiteral")
162 if (schema.ast._tag === "TemplateLiteral") {
163 assert.strictEqual(schema.ast.parts[1]._tag, "String")
164 }
165 assert.isTrue(Schema.is(schema)("prefix-value"))
166 })
167
168 it("revives nested references in a TemplateLiteral union", () => {
169 const schema = assertRepresentationRoundtrip(
170 Schema.TemplateLiteral([
171 "prefix-",
172 Schema.Union([
173 Schema.Literal("a").annotate({ identifier: "A" }),
174 Schema.Literal("b").annotate({ identifier: "B" })
175 ]).annotate({ identifier: "Part" })
176 ])
177 )
178 assert.isTrue(Schema.is(schema)("prefix-a"))
179 assert.isTrue(Schema.is(schema)("prefix-b"))
180 assert.isFalse(Schema.is(schema)("prefix-c"))
181 })
182
183 it("revives Tuple", () => {
184 assertRepresentationRoundtrip(Schema.Tuple([Schema.String, Schema.optionalKey(Schema.Number)]))
185 })
186
187 it("revives Array", () => {
188 assertRepresentationRoundtrip(Schema.Array(Schema.String))
189 })
190
191 it("revives TupleWithRest", () => {
192 assertRepresentationRoundtrip(
193 Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean])
194 )
195 })
196
197 it("revives Struct", () => {
198 assertRepresentationRoundtrip(Schema.Struct({
199 required: Schema.String,
200 optional: Schema.optionalKey(Schema.Number),
201 mutable: Schema.mutableKey(Schema.Boolean)
202 }))
203 })
204
205 it("revives Record", () => {
206 assertRepresentationRoundtrip(Schema.Record(Schema.String, Schema.Number))
207 })
208
209 it("revives a reference used as a Record key", () => {
210 const schema = assertRepresentationRoundtrip(
211 Schema.Record(Schema.String.annotate({ identifier: "Key" }), Schema.Number)
212 )
213 assert.strictEqual(schema.ast._tag, "Objects")
214 if (schema.ast._tag === "Objects") {
215 assert.strictEqual(schema.ast.indexSignatures[0].parameter._tag, "String")
216 }
217 assert.deepStrictEqual(Schema.decodeUnknownSync(schema as Schema.Codec<unknown>)({ a: 1 }), { a: 1 })
218 })
219
220 it("revives StructWithRest", () => {
221 assertRepresentationRoundtrip(
222 Schema.StructWithRest(Schema.Struct({ value: Schema.Number }), [Schema.Record(Schema.Symbol, Schema.String)])
223 )
224 })
225
226 it("revives Union", () => {
227 const schema = assertRepresentationRoundtrip(Schema.Union([Schema.String, Schema.Number]))
228 assert.isTrue(Schema.is(schema)("value"))
229 assert.isTrue(Schema.is(schema)(1))
230 assert.isFalse(Schema.is(schema)(true))
231 })
232
233 it("revives Union options through live and JSON representation roundtrips", () => {
234 const representation = {
235 _tag: "Union",
236 types: [{ _tag: "String", checks: [] }, { _tag: "Number", checks: [] }],
237 options: { mode: "oneOf" },
238 checks: []
239 } as const satisfies SchemaRepresentation.Representation
240 const document = { representation, references: {} }
241 const revived = SchemaRepresentation.fromRepresentation(document, { revivers: [] })
242 assert.strictEqual(revived.ast._tag, "Union")
243 if (revived.ast._tag === "Union") assert.deepStrictEqual(revived.ast.options, representation.options)
244 const fromJson = SchemaRepresentation.fromRepresentation(
245 SchemaRepresentation.fromJson(SchemaRepresentation.toJson(document)),
246 { revivers: [] }
247 )
248 assert.strictEqual(fromJson.ast._tag, "Union")
249 if (fromJson.ast._tag === "Union") assert.deepStrictEqual(fromJson.ast.options, representation.options)
250 })
251
252 it("revives an empty Union as Never", () => {
253 const schema = SchemaRepresentation.fromRepresentation({
254 representation: { _tag: "Union", types: [], options: { mode: "anyOf" }, checks: [] },
255 references: {}
256 }, { revivers: [] })
257 assert.isFalse(Schema.is(schema)(undefined))
258 assert.isFalse(Schema.is(schema)(null))
259 })
260
261 it("revives Suspend", () => {
262 interface Category {
263 readonly name: string
264 readonly children: ReadonlyArray<Category>
265 }
266 const Category: Schema.Codec<Category> = Schema.Struct({
267 name: Schema.String,
268 children: Schema.Array(Schema.suspend((): Schema.Codec<Category> => Category))
269 }).annotate({ identifier: "Category" })
270 const schema = revive(Category) as Schema.Codec<unknown>
271 assert.strictEqual(schema.ast._tag, "Objects")
272 if (schema.ast._tag === "Objects") {
273 const children = schema.ast.propertySignatures.find((property) => property.name === "children")
274 assert.isDefined(children)
275 assert.strictEqual(children.type._tag, "Arrays")
276 if (children.type._tag === "Arrays") {
277 assert.strictEqual(children.type.rest[0]._tag, "Suspend")
278 }
279 }
280 assert.deepStrictEqual(
281 Schema.decodeUnknownSync(schema)({
282 name: "root",
283 children: [{ name: "child", children: [] }]
284 }),
285 {
286 name: "root",
287 children: [{ name: "child", children: [] }]
288 }
289 )
290 assert.strictEqual(SchemaRepresentation.toRepresentation(schema.ast).representation._tag, "Reference")
291 })
292
293 it("restores node annotations", () => {
294 const schema = revive(Schema.String.annotate({ title: "Name" }))
295 assert.strictEqual(schema.ast.annotations?.title, "Name")
296 })
297
298 it("restores node annotations before checks", () => {
299 const schema = assertRepresentationRoundtrip(
300 Schema.String
301 .annotate({ title: "node" })
302 .check(minLengthCheck(2, { description: "check" })),
303 [minLengthReviver]
304 )
305
306 assert.strictEqual(schema.ast.annotations?.title, "node")
307 assert.strictEqual(schema.ast.checks?.[0].annotations?.description, "check")
308 assert.strictEqual(schema.ast.checks?.[0].annotations?.title, undefined)
309 })
310
311 it("restores tuple element annotations", () => {
312 const schema = revive(Schema.Tuple([Schema.String.annotateKey({ description: "element" })]))
313 const representation = SchemaRepresentation.toRepresentation(schema.ast).representation
314 assert.strictEqual(representation._tag, "Arrays")
315 if (representation._tag === "Arrays") {
316 assert.strictEqual(representation.elements[0].annotations?.description, "element")
317 }
318 })
319
320 it("restores property annotations", () => {
321 const schema = revive(Schema.Struct({ value: Schema.String.annotateKey({ description: "property" }) }))
322 const representation = SchemaRepresentation.toRepresentation(schema.ast).representation
323 assert.strictEqual(representation._tag, "Objects")
324 if (representation._tag === "Objects") {
325 assert.strictEqual(representation.propertySignatures[0].annotations?.description, "property")
326 }
327 })
328
329 it("restores checks added by fromBrand", () => {
330 type Int = number & Brand.Brand<"Int">
331 const Int = Brand.check<Int>(Schema.isInt())
332 type Positive = number & Brand.Brand<"Positive">
333 const Positive = Brand.check<Positive>(Schema.isGreaterThan(0))
334 const schema = Schema.Number.pipe(
335 Schema.fromBrand("Int", Int),
336 Schema.fromBrand("Positive", Positive)
337 )
338
339 const restored = assertRepresentationRoundtrip(schema, [
340 SchemaRepresentation.isIntReviver,
341 SchemaRepresentation.isGreaterThanReviver
342 ])
343 assert.isTrue(Schema.is(restored)(1))
344 assert.isFalse(Schema.is(restored)(1.2))
345 assert.isFalse(Schema.is(restored)(-1))
346 })
347
348 it("restores a node representation annotation without schema dependencies", () => {
349 assertRepresentationRoundtrip(Schema.String.annotate({
350 representation: { id: "acme/schema/String", payload: null }
351 }))
352 })
353
354 it("restores a node representation annotation with schema dependencies", () => {
355 assertRepresentationRoundtrip(Schema.String.annotate({
356 representation: {
357 id: "acme/schema/String",
358 payload: null,
359 schemas: [Schema.Number.ast]
360 }
361 }))
362 })
363
364 it("revives a Filter", () => {
365 const schema = assertRepresentationRoundtrip(
366 Schema.String.check(minLengthCheck(2, { description: "at least two" }).abort()),
367 [minLengthReviver]
368 )
369 assert.strictEqual(Schema.decodeUnknownResult(schema as Schema.Codec<unknown>)("a")._tag, "Failure")
370 assert.strictEqual(schema.ast.checks?.[0]._tag, "Filter")
371 assert.isTrue(schema.ast.checks?.[0]._tag === "Filter" && schema.ast.checks[0].aborted)
372 assert.strictEqual(schema.ast.checks?.[0].annotations?.description, "at least two")
373 })
374
375 it("revives a FilterGroup without an identity from its children", () => {
376 const group = Schema.makeFilterGroup([minLengthCheck(2), minLengthCheck(3)], { description: "both" })
377 const schema = assertRepresentationRoundtrip(Schema.String.check(group), [minLengthReviver])
378 assert.strictEqual(Schema.decodeUnknownResult(schema as Schema.Codec<unknown>)("ab")._tag, "Failure")
379 assert.strictEqual(schema.ast.checks?.[0].annotations?.description, "both")
380 })
381
382 it("uses an identified FilterGroup reviver instead of its persisted children", () => {
383 const groupId = "acme/schema/group"
384 const document = SchemaRepresentation.fromJson({
385 representation: {
386 _tag: "String",
387 checks: [{
388 _tag: "FilterGroup",
389 representation: { id: groupId, payload: null },
390 checks: [{
391 _tag: "Filter",
392 representation: { id: filterId, payload: { minimum: 1 } },
393 aborted: false
394 }]
395 }]
396 },
397 references: {}
398 })
399 const reviver: SchemaRepresentation.FilterGroupReviver<null> = {
400 id: groupId,
401 payloadSchema: Schema.Null,
402 revive: () => Schema.makeFilterGroup([Schema.makeFilter<string>((value) => value !== "blocked")])
403 }
404 const schema = SchemaRepresentation.fromRepresentation(document, { revivers: [reviver] }) as Schema.Codec<unknown>
405 assert.strictEqual(Schema.decodeUnknownSync(schema)("allowed"), "allowed")
406 assert.strictEqual(Schema.decodeUnknownResult(schema)("blocked")._tag, "Failure")
407 })
408
409 it("revives a Declaration", () => {
410 const id = "acme/schema/Box"
411 const Box = Schema.declare<{ readonly value: string }>(
412 (input): input is { readonly value: string } =>
413 typeof input === "object" && input !== null && typeof (input as any).value === "string",
414 { representation: { id, payload: { label: "Box" } } }
415 )
416 const reviver: SchemaRepresentation.DeclarationReviver<{ readonly label: string }> = {
417 id,
418 payloadSchema: Schema.Struct({ label: Schema.String }),
419 revive: ({ annotations, payload }) =>
420 Schema.declare<{ readonly value: string }>(
421 (input): input is { readonly value: string } =>
422 typeof input === "object" && input !== null && typeof (input as any).value === "string",
423 { ...annotations, representation: { id, payload } }
424 )
425 }
426 const schema = assertRepresentationRoundtrip(Box, [reviver]) as Schema.Codec<unknown>
427 assert.deepStrictEqual(Schema.decodeUnknownSync(schema)({ value: "ok" }), { value: "ok" })
428 })
429
430 it("reports a missing reviver", () => {
431 assert.strictEqual(
432 errorFrom(() =>
433 SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(filterJson()), { revivers: [] })
434 )
435 .message,
436 `Missing reviver for ${filterId}\n at ["representation"]["checks"][0]["representation"]`
437 )
438 })
439
440 it("rejects duplicate reviver IDs", () => {
441 assert.strictEqual(
442 errorFrom(() =>
443 SchemaRepresentation.fromRepresentation({
444 representation: { _tag: "String", checks: [] },
445 references: {}
446 }, { revivers: [minLengthReviver, minLengthReviver] })
447 ).message,
448 `Duplicate reviver for ${filterId}\n at ["revivers"][1]["id"]`
449 )
450 })
451
452 it("rejects an invalid reviver payload", () => {
453 const json = filterJson() as any
454 json.representation.checks[0].representation.payload = { minimum: "two" }
455 assert.strictEqual(
456 errorFrom(() =>
457 SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(json), {
458 revivers: [minLengthReviver]
459 })
460 ).message,
461 `Invalid representation payload for ${filterId}\n at ["representation"]["checks"][0]["representation"]["payload"]`
462 )
463 })
464
465 it("preserves a reviver exception by identity", () => {
466 const cause = new Error("boom")
467 const reviver = {
468 ...minLengthReviver,
469 revive: () => {
470 throw cause
471 }
472 }
473 assert.strictEqual(
474 errorFrom(() =>
475 SchemaRepresentation.fromRepresentation(SchemaRepresentation.fromJson(filterJson()), {
476 revivers: [reviver]
477 })
478 ),
479 cause
480 )
481 })
482
483 it("requires a representation identity on a Filter", () => {
484 assert.strictEqual(
485 errorFrom(() =>
486 SchemaRepresentation.fromRepresentation(
487 {
488 representation: { _tag: "String", checks: [{ _tag: "Filter", aborted: false }] },
489 references: {}
490 },
491 { revivers: [] }
492 )
493 ).message,
494 `Missing representation annotation\n at ["representation"]["checks"][0]["representation"]`
495 )
496 })
497
498 it("requires a representation identity on a Declaration", () => {
499 assert.strictEqual(
500 errorFrom(() =>
501 SchemaRepresentation.fromRepresentation(
502 {
503 representation: { _tag: "Declaration", typeParameters: [], checks: [] },
504 references: {}
505 },
506 { revivers: [] }
507 )
508 ).message,
509 `Missing representation annotation\n at ["representation"]["representation"]`
510 )
511 })
512
513 it("reports an invalid reference", () => {
514 assert.strictEqual(
515 errorFrom(() =>
516 SchemaRepresentation.fromRepresentation({
517 representation: { _tag: "Reference", $ref: "Missing" },
518 references: {}
519 }, { revivers: [] })
520 ).message,
521 `Invalid reference Missing\n at ["representation"]["$ref"]`
522 )
523 })
524})