1import { assert, describe, it } from "@effect/vitest"
2import { Effect, Schema } from "effect"
3import * as Arbitrary from "effect/Arbitrary"
4import * as DateTime from "effect/DateTime"
5import { assertSchemaIssueError, deepStrictEqual, strictEqual, throws } from "../utils/assert.ts"
6
7/**
8 * This suite intentionally avoids re-testing generic JSON Patch behavior
9 * (pointer parsing, add/replace/remove mechanics, ordering, etc).
10 *
11 * It focuses on Schema.toDifferJsonPatch-specific guarantees:
12 * - reference preservation when patch is a no-op
13 * - reference replacement for root replace
14 * - immutability of inputs
15 * - schema-specific diff/patch encodings (Number / Date / Defect / DateTimeUtcFromMillis)
16 * - property-based roundtrip across a broad set of codecs
17 */
18
19const SerializableSymbol = Schema.Symbol.check(
20 Schema.makeFilter((symbol) => {
21 const key = Symbol.keyFor(symbol)
22 return key !== undefined && !/[\n\r\u2028\u2029]/.test(key)
23 })
24)
25const SerializablePropertyKey = Schema.Union([SerializableSymbol, Schema.Finite, Schema.String])
26
27const roundtrip = Effect.fnUntraced(function*<T, E>(codec: Schema.Codec<T, E>) {
28 const differ = Schema.toDifferJsonPatch(codec)
29 const arbitrary = Arbitrary.schema(Schema.Tuple([codec, codec]))
30 const result = yield* Arbitrary.checkEffect(
31 arbitrary,
32 ([v1, v2]) => {
33 // avoid prototype-poisoning-ish values that aren't valid JSON-ish containers for patching
34 if (
35 [v1, v2].some((value) =>
36 typeof value === "object" &&
37 value !== null &&
38 (Object.getPrototypeOf(value) === null || Object.hasOwn(value as any, "__proto__"))
39 )
40 ) {
41 return true
42 }
43 const patch = differ.diff(v1, v2)
44 const patched = differ.patch(v1, patch)
45
46 // two invalid dates are not considered equal by deepStrictEqual
47 if (patched instanceof Date && v2 instanceof Date && Object.is(patched.getTime(), v2.getTime())) {
48 return true
49 }
50
51 deepStrictEqual(patched, v2)
52 return true
53 },
54 { runs: 100, seed: "toDifferJsonPatch" }
55 )
56 assert.strictEqual(result._tag, "Passed")
57})
58
59describe("Schema.toDifferJsonPatch", () => {
60 describe("structural guarantees", () => {
61 it("patch returns the same reference if nothing changed (no-op patch)", () => {
62 const schema = Schema.Struct({ a: Schema.String })
63 const differ = Schema.toDifferJsonPatch(schema)
64 const value = { a: "a" }
65
66 strictEqual(differ.patch(value, []), value)
67 })
68
69 it("root replace returns the provided reference (no clone)", () => {
70 const differ = Schema.toDifferJsonPatch(Schema.Any)
71
72 const newRef = { hello: "world" }
73 const out = differ.patch({ old: true }, [{ op: "replace", path: "", value: newRef }])
74
75 strictEqual(out, newRef)
76 deepStrictEqual(out, newRef)
77 })
78
79 it("immutability: patch does not mutate the input", () => {
80 const differ = Schema.toDifferJsonPatch(Schema.Any)
81
82 const oldValue = { a: { b: [1, 2, 3] } }
83 const snapshot = JSON.parse(JSON.stringify(oldValue))
84
85 const out = differ.patch(oldValue, [
86 { op: "replace", path: "/a/b/1", value: 9 },
87 { op: "remove", path: "/a/b/2" }
88 ])
89
90 deepStrictEqual(oldValue, snapshot)
91 deepStrictEqual(out, { a: { b: [1, 9] } })
92 })
93 })
94
95 describe("codec-specific diff/patch semantics", () => {
96 it("Number: distinguishes 0 and -0; treats NaN as equal to NaN", () => {
97 const differ = Schema.toDifferJsonPatch(Schema.Number)
98
99 deepStrictEqual(differ.diff(0, -0), [{ op: "replace", path: "", value: -0 }])
100 deepStrictEqual(differ.diff(-0, 0), [{ op: "replace", path: "", value: 0 }])
101
102 deepStrictEqual(differ.diff(NaN, NaN), [])
103 deepStrictEqual(differ.diff(Infinity, Infinity), [])
104 deepStrictEqual(differ.diff(-Infinity, -Infinity), [])
105
106 deepStrictEqual(differ.patch(0, [{ op: "replace", path: "", value: -0 }]), -0)
107 deepStrictEqual(differ.patch(-0, [{ op: "replace", path: "", value: 0 }]), 0)
108 })
109
110 it("Date: rejects an invalid Date on diff", () => {
111 const differ = Schema.toDifferJsonPatch(Schema.Date)
112
113 throws(() => differ.diff(new Date("1970-01-01T00:00:00.000Z"), new Date(NaN)), (error) => {
114 assertSchemaIssueError(error, "Expected a valid Date")
115 })
116 })
117
118 it("Defect: diff encodes an Error to a plain object; patch decodes back to Error", () => {
119 const differ = Schema.toDifferJsonPatch(Schema.Defect())
120
121 deepStrictEqual(differ.diff("", new Error("b")), [{
122 op: "replace",
123 path: "",
124 value: { name: "Error", message: "b" }
125 }])
126
127 deepStrictEqual(
128 differ.patch("", [{
129 op: "replace",
130 path: "",
131 value: { name: "Error", message: "b" }
132 }]),
133 new Error("b")
134 )
135 })
136
137 it("DateTimeUtcFromMillis: diff uses millis and patch rehydrates DateTime", () => {
138 const differ = Schema.toDifferJsonPatch(Schema.DateTimeUtcFromMillis)
139
140 deepStrictEqual(
141 differ.diff(
142 DateTime.makeUnsafe("2021-01-01T00:00:00.000Z"),
143 DateTime.makeUnsafe("2021-01-01T00:00:00.000Z")
144 ),
145 []
146 )
147
148 deepStrictEqual(
149 differ.diff(
150 DateTime.makeUnsafe("2021-01-01T00:00:00.000Z"),
151 DateTime.makeUnsafe("2021-01-02T00:00:00.000Z")
152 ),
153 [{ op: "replace", path: "", value: 1609545600000 }]
154 )
155
156 deepStrictEqual(
157 differ.patch(
158 DateTime.makeUnsafe("2021-01-01T00:00:00.000Z"),
159 [{ op: "replace", path: "", value: 1609545600000 }]
160 ),
161 DateTime.makeUnsafe("2021-01-02T00:00:00.000Z")
162 )
163 })
164 })
165
166 describe("integration sanity checks", () => {
167 it("diff/patch works when root container kind changes", () => {
168 const differ = Schema.toDifferJsonPatch(Schema.Any)
169
170 deepStrictEqual(differ.diff([], {}), [{ op: "replace", path: "", value: {} }])
171 deepStrictEqual(differ.patch([], [{ op: "replace", path: "", value: {} }]), {})
172 })
173
174 it("patch throws when asked to do an invalid replace (replace requires existence)", () => {
175 const differ = Schema.toDifferJsonPatch(Schema.Any)
176 const doc = {}
177 throws(() => differ.patch(doc, [{ op: "replace", path: "/x", value: 1 }]))
178 })
179 })
180
181 describe("roundtrip (property-based)", () => {
182 it.effect("patch(diff(a,b)) round-trips for a wide set of codecs", () =>
183 Effect.gen(function*() {
184 yield* roundtrip(Schema.Any)
185
186 yield* roundtrip(Schema.String)
187 yield* roundtrip(Schema.Number)
188 yield* roundtrip(Schema.Boolean)
189 yield* roundtrip(Schema.BigInt)
190 yield* roundtrip(SerializableSymbol)
191
192 // includes edgey keys without re-testing pointer logic exhaustively
193 yield* roundtrip(Schema.Struct({
194 a: Schema.String,
195 "-": Schema.NullOr(Schema.String),
196 "": Schema.String
197 }))
198
199 yield* roundtrip(Schema.Record(Schema.String, Schema.Number))
200 yield* roundtrip(Schema.StructWithRest(
201 Schema.Struct({
202 a: Schema.Number,
203 "-": Schema.Number,
204 "": Schema.Number
205 }),
206 [Schema.Record(Schema.String, Schema.Number)]
207 ))
208
209 yield* roundtrip(Schema.Tuple([Schema.String, Schema.Number]))
210 yield* roundtrip(Schema.Array(Schema.Number))
211
212 yield* roundtrip(Schema.TupleWithRest(
213 Schema.Tuple([Schema.Number]),
214 [Schema.String]
215 ))
216 yield* roundtrip(Schema.TupleWithRest(
217 Schema.Tuple([Schema.Number]),
218 [Schema.String, Schema.Boolean]
219 ))
220
221 yield* roundtrip(Schema.Union([Schema.String, Schema.Finite]))
222
223 yield* roundtrip(Schema.Finite)
224 yield* roundtrip(Schema.Date)
225 yield* roundtrip(Schema.URL)
226 yield* roundtrip(Schema.RegExp)
227 yield* roundtrip(Schema.Duration)
228 yield* roundtrip(Schema.DateTimeUtc)
229 yield* roundtrip(Schema.Uint8Array)
230 yield* roundtrip(SerializablePropertyKey)
231 yield* roundtrip(Schema.Option(Schema.String))
232 yield* roundtrip(Schema.Result(Schema.Number, Schema.String))
233 yield* roundtrip(Schema.ReadonlyMap(Schema.String, Schema.Number))
234 yield* roundtrip(Schema.ErrorInstance())
235 yield* roundtrip(Schema.Json)
236 yield* roundtrip(Schema.Exit(Schema.Number, Schema.String, Schema.Json))
237
238 class A extends Schema.Class<A>("A")({ value: Schema.Number }) {}
239 class B extends Schema.Class<B>("B")({ a: A }) {}
240 yield* roundtrip(B)
241
242 class E extends Schema.Error<E>("E")({ message: Schema.String }) {}
243 yield* roundtrip(E)
244 }))
245 })
246})