effect-app 4.0.0-beta.7 → 4.0.0-beta.70

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 (138) hide show
  1. package/CHANGELOG.md +307 -0
  2. package/dist/Config/SecretURL.js +2 -2
  3. package/dist/Config.d.ts +7 -0
  4. package/dist/Config.d.ts.map +1 -0
  5. package/dist/Config.js +6 -0
  6. package/dist/ConfigProvider.d.ts +39 -0
  7. package/dist/ConfigProvider.d.ts.map +1 -0
  8. package/dist/ConfigProvider.js +42 -0
  9. package/dist/Context.d.ts +40 -0
  10. package/dist/Context.d.ts.map +1 -0
  11. package/dist/Context.js +66 -0
  12. package/dist/Effect.d.ts +8 -7
  13. package/dist/Effect.d.ts.map +1 -1
  14. package/dist/Effect.js +3 -2
  15. package/dist/Layer.d.ts +5 -4
  16. package/dist/Layer.d.ts.map +1 -1
  17. package/dist/Layer.js +1 -1
  18. package/dist/Operations.d.ts +54 -18
  19. package/dist/Operations.d.ts.map +1 -1
  20. package/dist/Pure.d.ts +2 -2
  21. package/dist/Pure.d.ts.map +1 -1
  22. package/dist/Pure.js +13 -13
  23. package/dist/Schema/Class.d.ts +39 -1
  24. package/dist/Schema/Class.d.ts.map +1 -1
  25. package/dist/Schema/Class.js +89 -12
  26. package/dist/Schema/SpecialJsonSchema.d.ts +21 -0
  27. package/dist/Schema/SpecialJsonSchema.d.ts.map +1 -0
  28. package/dist/Schema/SpecialJsonSchema.js +59 -0
  29. package/dist/Schema/SpecialOpenApi.d.ts +32 -0
  30. package/dist/Schema/SpecialOpenApi.d.ts.map +1 -0
  31. package/dist/Schema/SpecialOpenApi.js +123 -0
  32. package/dist/Schema/brand.d.ts +8 -5
  33. package/dist/Schema/brand.d.ts.map +1 -1
  34. package/dist/Schema/brand.js +1 -1
  35. package/dist/Schema/email.d.ts.map +1 -1
  36. package/dist/Schema/email.js +9 -4
  37. package/dist/Schema/ext.d.ts +81 -44
  38. package/dist/Schema/ext.d.ts.map +1 -1
  39. package/dist/Schema/ext.js +76 -36
  40. package/dist/Schema/moreStrings.d.ts +4 -4
  41. package/dist/Schema/moreStrings.d.ts.map +1 -1
  42. package/dist/Schema/moreStrings.js +10 -5
  43. package/dist/Schema/numbers.d.ts +8 -8
  44. package/dist/Schema/numbers.js +2 -2
  45. package/dist/Schema/phoneNumber.d.ts.map +1 -1
  46. package/dist/Schema/phoneNumber.js +8 -3
  47. package/dist/Schema/strings.d.ts +4 -4
  48. package/dist/Schema/strings.d.ts.map +1 -1
  49. package/dist/Schema.d.ts +22 -55
  50. package/dist/Schema.d.ts.map +1 -1
  51. package/dist/Schema.js +43 -64
  52. package/dist/client/apiClientFactory.d.ts +3 -3
  53. package/dist/client/apiClientFactory.d.ts.map +1 -1
  54. package/dist/client/apiClientFactory.js +14 -15
  55. package/dist/client/errors.d.ts +16 -8
  56. package/dist/client/errors.d.ts.map +1 -1
  57. package/dist/client/errors.js +35 -10
  58. package/dist/client/makeClient.d.ts +13 -12
  59. package/dist/client/makeClient.d.ts.map +1 -1
  60. package/dist/client/makeClient.js +5 -2
  61. package/dist/http/Request.d.ts.map +1 -1
  62. package/dist/http/Request.js +5 -5
  63. package/dist/ids.d.ts +1 -1
  64. package/dist/ids.d.ts.map +1 -1
  65. package/dist/ids.js +1 -1
  66. package/dist/index.d.ts +3 -8
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -9
  69. package/dist/middleware.d.ts +2 -2
  70. package/dist/middleware.d.ts.map +1 -1
  71. package/dist/middleware.js +3 -3
  72. package/dist/rpc/MiddlewareMaker.d.ts +4 -3
  73. package/dist/rpc/MiddlewareMaker.d.ts.map +1 -1
  74. package/dist/rpc/MiddlewareMaker.js +7 -6
  75. package/dist/rpc/RpcContextMap.d.ts +2 -2
  76. package/dist/rpc/RpcContextMap.d.ts.map +1 -1
  77. package/dist/rpc/RpcContextMap.js +4 -4
  78. package/dist/rpc/RpcMiddleware.d.ts +4 -3
  79. package/dist/rpc/RpcMiddleware.d.ts.map +1 -1
  80. package/dist/rpc/RpcMiddleware.js +1 -1
  81. package/dist/utils/gen.d.ts +1 -1
  82. package/dist/utils/gen.d.ts.map +1 -1
  83. package/dist/utils/logger.d.ts +2 -2
  84. package/dist/utils/logger.d.ts.map +1 -1
  85. package/dist/utils/logger.js +3 -3
  86. package/dist/utils.d.ts +18 -0
  87. package/dist/utils.d.ts.map +1 -1
  88. package/dist/utils.js +24 -5
  89. package/package.json +29 -17
  90. package/src/Config/SecretURL.ts +1 -1
  91. package/src/Config.ts +14 -0
  92. package/src/ConfigProvider.ts +48 -0
  93. package/src/{ServiceMap.ts → Context.ts} +57 -64
  94. package/src/Effect.ts +11 -9
  95. package/src/Layer.ts +5 -4
  96. package/src/Pure.ts +17 -18
  97. package/src/Schema/Class.ts +114 -16
  98. package/src/Schema/SpecialJsonSchema.ts +69 -0
  99. package/src/Schema/SpecialOpenApi.ts +130 -0
  100. package/src/Schema/brand.ts +13 -7
  101. package/src/Schema/email.ts +10 -2
  102. package/src/Schema/ext.ts +150 -59
  103. package/src/Schema/moreStrings.ts +14 -6
  104. package/src/Schema/numbers.ts +2 -2
  105. package/src/Schema/phoneNumber.ts +8 -1
  106. package/src/Schema.ts +79 -103
  107. package/src/client/apiClientFactory.ts +18 -18
  108. package/src/client/errors.ts +46 -12
  109. package/src/client/makeClient.ts +32 -12
  110. package/src/http/Request.ts +7 -4
  111. package/src/ids.ts +1 -1
  112. package/src/index.ts +3 -11
  113. package/src/middleware.ts +2 -2
  114. package/src/rpc/MiddlewareMaker.ts +8 -7
  115. package/src/rpc/RpcContextMap.ts +6 -5
  116. package/src/rpc/RpcMiddleware.ts +5 -4
  117. package/src/utils/gen.ts +1 -1
  118. package/src/utils/logger.ts +2 -2
  119. package/src/utils.ts +26 -4
  120. package/test/dist/moreStrings.test.d.ts.map +1 -0
  121. package/test/dist/rpc.test.d.ts.map +1 -1
  122. package/test/dist/secretURL.test.d.ts.map +1 -0
  123. package/test/dist/special.test.d.ts.map +1 -0
  124. package/test/moreStrings.test.ts +17 -0
  125. package/test/rpc.test.ts +26 -5
  126. package/test/schema.test.ts +396 -3
  127. package/test/secretURL.test.ts +157 -0
  128. package/test/special.test.ts +732 -0
  129. package/test/utils.test.ts +1 -1
  130. package/tsconfig.base.json +0 -1
  131. package/tsconfig.json +0 -1
  132. package/dist/ServiceMap.d.ts +0 -44
  133. package/dist/ServiceMap.d.ts.map +0 -1
  134. package/dist/ServiceMap.js +0 -91
  135. package/dist/Struct.d.ts +0 -44
  136. package/dist/Struct.d.ts.map +0 -1
  137. package/dist/Struct.js +0 -29
  138. package/src/Struct.ts +0 -54
@@ -0,0 +1,732 @@
1
+ import { Option, Predicate, Schema, SchemaGetter } from "effect"
2
+ import { InvalidStateError, LoginError, NotFoundError, NotLoggedInError, OptimisticConcurrencyException, ServiceUnavailableError, UnauthorizedError, ValidationError } from "effect-app/client/errors"
3
+ import * as AppSchema from "effect-app/Schema"
4
+ import { Class, TaggedClass } from "effect-app/Schema/Class"
5
+ import { flattenSimpleAllOf, specialJsonSchemaDocument } from "effect-app/Schema/SpecialJsonSchema"
6
+ import { deduplicateOpenApiSchemas } from "effect-app/Schema/SpecialOpenApi"
7
+ import * as S from "effect/Schema"
8
+ import { describe, expect, it } from "vitest"
9
+
10
+ describe("Class", () => {
11
+ it("encoding accepts plain objects matching the struct (Fields argument)", () => {
12
+ class A extends Class<A>("A")({ a: S.String }) {}
13
+
14
+ // Encoding a class instance still works
15
+ expect(S.encodeUnknownSync(A)(new A({ a: "hello" }))).toStrictEqual({ a: "hello" })
16
+
17
+ // Encoding a plain object matching the struct now succeeds
18
+ expect(S.encodeUnknownSync(A)({ a: "world" })).toStrictEqual({ a: "world" })
19
+
20
+ // Encoding null still fails
21
+ expect(() => S.encodeUnknownSync(A)(null)).toThrow()
22
+ })
23
+
24
+ it("encoding accepts plain objects matching the struct (Struct argument)", () => {
25
+ class A extends Class<A>("A")(S.Struct({ a: S.String })) {}
26
+
27
+ expect(S.encodeUnknownSync(A)(new A({ a: "hello" }))).toStrictEqual({ a: "hello" })
28
+ expect(S.encodeUnknownSync(A)({ a: "world" })).toStrictEqual({ a: "world" })
29
+ expect(() => S.encodeUnknownSync(A)(null)).toThrow()
30
+ })
31
+
32
+ it("decoding still works normally", () => {
33
+ class A extends Class<A>("A")({ a: S.String }) {}
34
+
35
+ const decoded = S.decodeUnknownSync(A)({ a: "hello" })
36
+ expect(decoded).toBeInstanceOf(A)
37
+ expect((decoded as A).a).toBe("hello")
38
+
39
+ expect(() => S.decodeUnknownSync(A)(null)).toThrow()
40
+ expect(() => S.decodeUnknownSync(A)({ a: 1 })).toThrow()
41
+ })
42
+
43
+ it("rejects values that don't match the struct", () => {
44
+ class A extends Class<A>("A")({ a: S.String }) {}
45
+
46
+ expect(() => S.encodeUnknownSync(A)({ a: 123 })).toThrow()
47
+ expect(() => S.encodeUnknownSync(A)("not an object")).toThrow()
48
+ })
49
+
50
+ it("returns a class constructor — new and instanceof work", () => {
51
+ class A extends Class<A>("A")({ a: S.String }) {}
52
+
53
+ const instance = new A({ a: "hello" })
54
+ expect(instance).toBeInstanceOf(A)
55
+ expect(instance.a).toBe("hello")
56
+ })
57
+
58
+ it("preserves fields and identifier", () => {
59
+ class A extends Class<A>("A")({ a: S.String, b: S.Number }) {}
60
+
61
+ expect(A.identifier).toBe("A")
62
+ expect(Object.keys(A.fields)).toStrictEqual(["a", "b"])
63
+ })
64
+ })
65
+
66
+ describe("Class constructor", () => {
67
+ it("works as a base class — new, instanceof, encoding plain objects", () => {
68
+ class A extends Class<A>("A")({ a: S.String }) {}
69
+
70
+ // Construction
71
+ const instance = new A({ a: "hello" })
72
+ expect(instance).toBeInstanceOf(A)
73
+ expect(instance.a).toBe("hello")
74
+
75
+ // Encoding a class instance
76
+ expect(S.encodeUnknownSync(A)(instance)).toStrictEqual({ a: "hello" })
77
+
78
+ // Encoding a plain object
79
+ expect(S.encodeUnknownSync(A)({ a: "world" })).toStrictEqual({ a: "world" })
80
+
81
+ // Encoding invalid input fails
82
+ expect(() => S.encodeUnknownSync(A)(null)).toThrow()
83
+ expect(() => S.encodeUnknownSync(A)({ a: 123 })).toThrow()
84
+ })
85
+
86
+ it("decoding works normally", () => {
87
+ class A extends Class<A>("A")({ a: S.String }) {}
88
+
89
+ const decoded = S.decodeUnknownSync(A)({ a: "hello" })
90
+ expect(decoded).toBeInstanceOf(A)
91
+ expect((decoded as A).a).toBe("hello")
92
+
93
+ expect(() => S.decodeUnknownSync(A)({ a: 1 })).toThrow()
94
+ })
95
+
96
+ it("exposes fields, identifier, pick, omit", () => {
97
+ class A extends Class<A>("A")({ a: S.String, b: S.Number }) {}
98
+
99
+ expect(A.identifier).toBe("A")
100
+ expect(Object.keys(A.fields)).toStrictEqual(["a", "b"])
101
+ expect(A.pick("a")).toStrictEqual({ a: A.fields.a })
102
+ expect(A.omit("b")).toStrictEqual({ a: A.fields.a })
103
+ })
104
+ })
105
+
106
+ describe("TaggedClass constructor", () => {
107
+ it("works as a base class with _tag — new, instanceof, encoding plain objects", () => {
108
+ class Circle extends TaggedClass<Circle>()("Circle", { radius: S.Number }) {}
109
+
110
+ // Construction
111
+ const instance = new Circle({ radius: 5 })
112
+ expect(instance).toBeInstanceOf(Circle)
113
+ expect(instance._tag).toBe("Circle")
114
+ expect(instance.radius).toBe(5)
115
+
116
+ // Encoding a class instance
117
+ expect(S.encodeUnknownSync(Circle)(instance)).toStrictEqual({ _tag: "Circle", radius: 5 })
118
+
119
+ // Encoding a plain object
120
+ expect(S.encodeUnknownSync(Circle)({ _tag: "Circle", radius: 10 })).toStrictEqual({ _tag: "Circle", radius: 10 })
121
+
122
+ // Encoding invalid input fails
123
+ expect(() => S.encodeUnknownSync(Circle)(null)).toThrow()
124
+ expect(() => S.encodeUnknownSync(Circle)({ _tag: "Circle", radius: "nope" })).toThrow()
125
+ })
126
+
127
+ it("decoding works normally", () => {
128
+ class Circle extends TaggedClass<Circle>()("Circle", { radius: S.Number }) {}
129
+
130
+ const decoded = S.decodeUnknownSync(Circle)({ _tag: "Circle", radius: 5 })
131
+ expect(decoded).toBeInstanceOf(Circle)
132
+ expect((decoded as Circle).radius).toBe(5)
133
+ expect((decoded as Circle)._tag).toBe("Circle")
134
+ })
135
+
136
+ it("exposes fields, identifier, pick, omit", () => {
137
+ class Circle extends TaggedClass<Circle>()("Circle", { radius: S.Number }) {}
138
+
139
+ expect(Circle.identifier).toBe("Circle")
140
+ expect(Object.keys(Circle.fields)).toContain("_tag")
141
+ expect(Object.keys(Circle.fields)).toContain("radius")
142
+ expect(Circle.pick("radius")).toStrictEqual({ radius: Circle.fields.radius })
143
+ })
144
+ })
145
+
146
+ describe("TaggedError", () => {
147
+ it("InvalidStateError toString includes the message", () => {
148
+ const error = new InvalidStateError("something went wrong")
149
+ expect(error.toString()).toContain("something went wrong")
150
+ })
151
+
152
+ it("NotFoundError toString includes the message", () => {
153
+ const error = new NotFoundError({ type: "User", id: "123" })
154
+ expect(error.toString()).toContain("Didn't find User")
155
+ expect(error.toString()).toContain("123")
156
+ })
157
+
158
+ it("ServiceUnavailableError toString includes the message", () => {
159
+ const error = new ServiceUnavailableError("service down")
160
+ expect(error.toString()).toContain("service down")
161
+ })
162
+
163
+ it("ValidationError toString includes the message", () => {
164
+ const error = new ValidationError({ errors: ["field required"] })
165
+ expect(error.toString()).toContain("Validation failed")
166
+ expect(error.toString()).toContain("field required")
167
+ })
168
+
169
+ it("NotLoggedInError toString includes the message", () => {
170
+ const error = new NotLoggedInError("not logged in")
171
+ expect(error.toString()).toContain("not logged in")
172
+ })
173
+
174
+ it("LoginError toString includes the message", () => {
175
+ const error = new LoginError("login failed")
176
+ expect(error.toString()).toContain("login failed")
177
+ })
178
+
179
+ it("UnauthorizedError toString includes the message", () => {
180
+ const error = new UnauthorizedError("forbidden")
181
+ expect(error.toString()).toContain("forbidden")
182
+ })
183
+
184
+ it("OptimisticConcurrencyException toString includes the message", () => {
185
+ const error = new OptimisticConcurrencyException({ message: "conflict" })
186
+ expect(error.toString()).toContain("conflict")
187
+ })
188
+
189
+ it("OptimisticConcurrencyException from details toString includes the message", () => {
190
+ const error = new OptimisticConcurrencyException({ type: "User", id: "123", code: 409 })
191
+ expect(error.toString()).toContain("Existing User 123 record changed")
192
+ })
193
+ })
194
+
195
+ describe("SpecialJsonSchema", () => {
196
+ it("nullable to optional — from NullOr", () => {
197
+ const nullableDecodedUndefinedEncoded = (schema: Schema.Top) => {
198
+ const isNullableSchema = "members" in schema
199
+ && globalThis.Array.isArray((schema as any).members)
200
+ && (schema as any).members.length === 2
201
+ && (schema as any).members.some((member: any) => member.ast._tag === "Null")
202
+
203
+ const nullableMembers = isNullableSchema ? (schema as any).members as ReadonlyArray<Schema.Top> : undefined
204
+ const innerSchema = nullableMembers
205
+ ? nullableMembers.find((member: any) => member.ast._tag !== "Null")!
206
+ : schema
207
+
208
+ const nullableSchema = isNullableSchema ? schema : Schema.NullOr(schema)
209
+
210
+ return nullableSchema.pipe(
211
+ Schema.encodeTo(Schema.optionalKey(innerSchema), {
212
+ decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
213
+ encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
214
+ })
215
+ )
216
+ }
217
+
218
+ const fromNullOr = nullableDecodedUndefinedEncoded(Schema.NullOr(Schema.String))
219
+ const structFromNullOr = Schema.Struct({ status: fromNullOr })
220
+
221
+ const encode = Schema.encodeUnknownSync(structFromNullOr as any)
222
+ const encodedNull = encode({ status: null }) as any
223
+ expect("status" in encodedNull).toBe(false)
224
+ expect(encode({ status: "test" })).toStrictEqual({ status: "test" })
225
+
226
+ const decode = Schema.decodeUnknownSync(structFromNullOr as any)
227
+ expect(decode({})).toStrictEqual({ status: null })
228
+ expect(decode({ status: "test" })).toStrictEqual({ status: "test" })
229
+
230
+ const doc = specialJsonSchemaDocument(structFromNullOr)
231
+ expect(doc).toStrictEqual({
232
+ dialect: "draft-2020-12",
233
+ schema: {
234
+ "type": "object",
235
+ "properties": {
236
+ "status": { "type": "string" }
237
+ },
238
+ "additionalProperties": false
239
+ },
240
+ definitions: {}
241
+ })
242
+ })
243
+
244
+ it("identifies X universally — deduplicates same-fingerprint references", () => {
245
+ const X = Schema.String.annotate({ title: "X", identifier: "X" })
246
+
247
+ const s = Schema.Struct({
248
+ a: Schema.NullOr(X).pipe(
249
+ Schema.encodeTo(Schema.optionalKey(X), {
250
+ decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
251
+ encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
252
+ })
253
+ ),
254
+ b: Schema.NullOr(X).pipe(
255
+ Schema.encodeTo(Schema.optionalKey(X), {
256
+ decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
257
+ encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
258
+ })
259
+ ),
260
+ c: Schema.NullOr(X),
261
+ d: X,
262
+ e: X.pipe(Schema.optionalKey)
263
+ })
264
+
265
+ const doc = specialJsonSchemaDocument(s)
266
+ expect(doc).toStrictEqual({
267
+ dialect: "draft-2020-12",
268
+ schema: {
269
+ "type": "object",
270
+ "properties": {
271
+ "a": { "$ref": "#/$defs/X" },
272
+ "b": { "$ref": "#/$defs/X" },
273
+ "c": {
274
+ "anyOf": [
275
+ { "$ref": "#/$defs/X" },
276
+ { "type": "null" }
277
+ ]
278
+ },
279
+ "d": { "$ref": "#/$defs/X" },
280
+ "e": { "$ref": "#/$defs/X" }
281
+ },
282
+ "required": ["c", "d"],
283
+ "additionalProperties": false
284
+ },
285
+ definitions: {
286
+ X: {
287
+ "type": "string",
288
+ "title": "X"
289
+ }
290
+ }
291
+ })
292
+ })
293
+
294
+ it("shared annotated schema via helper — deduplicates", () => {
295
+ const X = Schema.String.annotate({ title: "X", identifier: "X" })
296
+
297
+ const cache = new WeakMap()
298
+ const nullableDecodedUndefinedEncoded = (schema: Schema.Top) => {
299
+ const isNullableSchema = "members" in schema
300
+ && globalThis.Array.isArray((schema as any).members)
301
+ && (schema as any).members.length === 2
302
+ && (schema as any).members.some((member: any) => member.ast._tag === "Null")
303
+
304
+ const nullableMembers = isNullableSchema ? (schema as any).members as ReadonlyArray<Schema.Top> : undefined
305
+ const innerSchema = nullableMembers
306
+ ? nullableMembers.find((member: any) => member.ast._tag !== "Null")!
307
+ : schema
308
+
309
+ const cached = cache.get(innerSchema.ast)
310
+ if (cached !== undefined) return cached
311
+
312
+ const nullableSchema = isNullableSchema ? schema : Schema.NullOr(schema)
313
+ const out = nullableSchema.pipe(
314
+ Schema.encodeTo(Schema.optionalKey(innerSchema), {
315
+ decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
316
+ encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
317
+ })
318
+ )
319
+
320
+ cache.set(innerSchema.ast, out)
321
+ return out
322
+ }
323
+
324
+ const structWithShared = Schema.Struct({
325
+ a: nullableDecodedUndefinedEncoded(X),
326
+ b: nullableDecodedUndefinedEncoded(Schema.NullOr(X)),
327
+ c: Schema.NullOr(X),
328
+ d: X,
329
+ e: X.pipe(Schema.optionalKey)
330
+ })
331
+
332
+ const doc = specialJsonSchemaDocument(structWithShared)
333
+ expect(doc).toStrictEqual({
334
+ dialect: "draft-2020-12",
335
+ schema: {
336
+ "type": "object",
337
+ "properties": {
338
+ "a": { "$ref": "#/$defs/X" },
339
+ "b": { "$ref": "#/$defs/X" },
340
+ "c": {
341
+ "anyOf": [
342
+ { "$ref": "#/$defs/X" },
343
+ { "type": "null" }
344
+ ]
345
+ },
346
+ "d": { "$ref": "#/$defs/X" },
347
+ "e": { "$ref": "#/$defs/X" }
348
+ },
349
+ "required": ["c", "d"],
350
+ "additionalProperties": false
351
+ },
352
+ definitions: {
353
+ X: {
354
+ "type": "string",
355
+ "title": "X"
356
+ }
357
+ }
358
+ })
359
+ })
360
+ })
361
+
362
+ describe("SpecialOpenApi", () => {
363
+ it("deduplicates identical components.schemas entries with same base identifier", () => {
364
+ const spec = {
365
+ openapi: "3.1.0",
366
+ info: { title: "Test", version: "1.0" },
367
+ paths: {
368
+ "/foo": {
369
+ get: {
370
+ responses: {
371
+ 200: {
372
+ content: {
373
+ "application/json": {
374
+ schema: { $ref: "#/components/schemas/X" }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ },
381
+ "/bar": {
382
+ get: {
383
+ responses: {
384
+ 200: {
385
+ content: {
386
+ "application/json": {
387
+ schema: { $ref: "#/components/schemas/X1" }
388
+ }
389
+ }
390
+ }
391
+ }
392
+ }
393
+ }
394
+ },
395
+ components: {
396
+ schemas: {
397
+ X: { type: "string", title: "X" },
398
+ X1: { type: "string", title: "X" }
399
+ }
400
+ }
401
+ }
402
+
403
+ const result = deduplicateOpenApiSchemas(spec) as any
404
+
405
+ // X1 should be removed, and $ref to X1 rewritten to X
406
+ expect(result.components.schemas).toStrictEqual({
407
+ X: { type: "string", title: "X" }
408
+ })
409
+ expect(
410
+ result.paths["/bar"].get.responses[200].content["application/json"].schema
411
+ )
412
+ .toStrictEqual({ $ref: "#/components/schemas/X" })
413
+ })
414
+
415
+ it("does not deduplicate entries with different representations", () => {
416
+ const spec = {
417
+ openapi: "3.1.0",
418
+ info: { title: "Test", version: "1.0" },
419
+ paths: {},
420
+ components: {
421
+ schemas: {
422
+ X: { type: "string", title: "X" },
423
+ X1: { type: "number", title: "X" }
424
+ }
425
+ }
426
+ }
427
+
428
+ const result = deduplicateOpenApiSchemas(spec) as any
429
+
430
+ // Both should remain since they have different representations
431
+ expect(result.components.schemas).toStrictEqual({
432
+ X: { type: "string", title: "X" },
433
+ X1: { type: "number", title: "X" }
434
+ })
435
+ })
436
+
437
+ it("returns spec unchanged when no duplicates exist", () => {
438
+ const spec = {
439
+ openapi: "3.1.0",
440
+ info: { title: "Test", version: "1.0" },
441
+ paths: {},
442
+ components: {
443
+ schemas: {
444
+ Foo: { type: "string" },
445
+ Bar: { type: "number" }
446
+ }
447
+ }
448
+ }
449
+
450
+ const result = deduplicateOpenApiSchemas(spec)
451
+ expect(result).toStrictEqual(spec)
452
+ })
453
+
454
+ it("rewrites nested $ref pointers in allOf/anyOf/oneOf", () => {
455
+ const spec = {
456
+ openapi: "3.1.0",
457
+ info: { title: "Test", version: "1.0" },
458
+ paths: {
459
+ "/baz": {
460
+ post: {
461
+ requestBody: {
462
+ content: {
463
+ "application/json": {
464
+ schema: {
465
+ anyOf: [
466
+ { $ref: "#/components/schemas/Y1" },
467
+ { type: "null" }
468
+ ]
469
+ }
470
+ }
471
+ }
472
+ }
473
+ }
474
+ }
475
+ },
476
+ components: {
477
+ schemas: {
478
+ Y: { type: "object", properties: { name: { type: "string" } } },
479
+ Y1: { type: "object", properties: { name: { type: "string" } } }
480
+ }
481
+ }
482
+ }
483
+
484
+ const result = deduplicateOpenApiSchemas(spec) as any
485
+
486
+ expect(result.components.schemas).toStrictEqual({
487
+ Y: { type: "object", properties: { name: { type: "string" } } }
488
+ })
489
+ expect(
490
+ result.paths["/baz"].post.requestBody.content["application/json"].schema.anyOf[0]
491
+ )
492
+ .toStrictEqual({ $ref: "#/components/schemas/Y" })
493
+ })
494
+
495
+ it("rewrites $ref pointers inside definitions themselves", () => {
496
+ const spec = {
497
+ openapi: "3.1.0",
498
+ info: { title: "Test", version: "1.0" },
499
+ paths: {},
500
+ components: {
501
+ schemas: {
502
+ Inner: { type: "string" },
503
+ Inner1: { type: "string" },
504
+ Outer: {
505
+ type: "object",
506
+ properties: {
507
+ field: { $ref: "#/components/schemas/Inner1" }
508
+ }
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ const result = deduplicateOpenApiSchemas(spec) as any
515
+
516
+ expect(Object.keys(result.components.schemas)).toStrictEqual(["Inner", "Outer"])
517
+ expect(result.components.schemas.Outer.properties.field).toStrictEqual({
518
+ $ref: "#/components/schemas/Inner"
519
+ })
520
+ })
521
+
522
+ it("handles spec without components gracefully", () => {
523
+ const spec = { openapi: "3.1.0", info: { title: "Test", version: "1.0" }, paths: {} }
524
+ const result = deduplicateOpenApiSchemas(spec)
525
+ expect(result).toStrictEqual(spec)
526
+ })
527
+
528
+ it("flattens allOf in components.schemas", () => {
529
+ const spec = {
530
+ openapi: "3.1.0",
531
+ info: { title: "Test", version: "1.0" },
532
+ paths: {},
533
+ components: {
534
+ schemas: {
535
+ PositiveInt: {
536
+ type: "integer",
537
+ allOf: [{ exclusiveMinimum: 0, title: "PositiveInt" }]
538
+ }
539
+ }
540
+ }
541
+ }
542
+
543
+ const result = deduplicateOpenApiSchemas(spec) as any
544
+
545
+ expect(result.components.schemas.PositiveInt).toStrictEqual({
546
+ type: "integer",
547
+ exclusiveMinimum: 0,
548
+ title: "PositiveInt"
549
+ })
550
+ })
551
+
552
+ it("does not flatten allOf containing $ref entries", () => {
553
+ const spec = {
554
+ openapi: "3.1.0",
555
+ info: { title: "Test", version: "1.0" },
556
+ paths: {},
557
+ components: {
558
+ schemas: {
559
+ Composed: {
560
+ type: "object",
561
+ allOf: [{ $ref: "#/components/schemas/Base" }]
562
+ },
563
+ Base: { type: "object", properties: { id: { type: "string" } } }
564
+ }
565
+ }
566
+ }
567
+
568
+ const result = deduplicateOpenApiSchemas(spec) as any
569
+
570
+ expect(result.components.schemas.Composed).toStrictEqual({
571
+ type: "object",
572
+ allOf: [{ $ref: "#/components/schemas/Base" }]
573
+ })
574
+ })
575
+
576
+ it("does not flatten allOf entries that define their own type", () => {
577
+ const spec = {
578
+ openapi: "3.1.0",
579
+ info: { title: "Test", version: "1.0" },
580
+ paths: {},
581
+ components: {
582
+ schemas: {
583
+ Mixed: {
584
+ type: "object",
585
+ allOf: [{ type: "string", minLength: 1 }]
586
+ }
587
+ }
588
+ }
589
+ }
590
+
591
+ const result = deduplicateOpenApiSchemas(spec) as any
592
+
593
+ expect(result.components.schemas.Mixed).toStrictEqual({
594
+ type: "object",
595
+ allOf: [{ type: "string", minLength: 1 }]
596
+ })
597
+ })
598
+ })
599
+
600
+ describe("flattenSimpleAllOf", () => {
601
+ it("flattens constraint-only allOf into parent with type", () => {
602
+ const input = {
603
+ type: "integer",
604
+ allOf: [{ exclusiveMinimum: 0, title: "PositiveInt" }]
605
+ }
606
+ expect(flattenSimpleAllOf(input)).toStrictEqual({
607
+ type: "integer",
608
+ exclusiveMinimum: 0,
609
+ title: "PositiveInt"
610
+ })
611
+ })
612
+
613
+ it("flattens string type with multiple constraints", () => {
614
+ const input = {
615
+ type: "string",
616
+ allOf: [
617
+ { minLength: 1, maxLength: 255 },
618
+ { title: "NonEmptyString255" }
619
+ ]
620
+ }
621
+ expect(flattenSimpleAllOf(input)).toStrictEqual({
622
+ type: "string",
623
+ minLength: 1,
624
+ maxLength: 255,
625
+ title: "NonEmptyString255"
626
+ })
627
+ })
628
+
629
+ it("does not flatten allOf with $ref", () => {
630
+ const input = {
631
+ type: "object",
632
+ allOf: [{ $ref: "#/components/schemas/Base" }]
633
+ }
634
+ expect(flattenSimpleAllOf(input)).toStrictEqual(input)
635
+ })
636
+
637
+ it("does not flatten allOf entries with their own type", () => {
638
+ const input = {
639
+ type: "object",
640
+ allOf: [{ type: "string", minLength: 1 }]
641
+ }
642
+ expect(flattenSimpleAllOf(input)).toStrictEqual(input)
643
+ })
644
+
645
+ it("allOf entry wins on property conflict", () => {
646
+ const input = {
647
+ type: "integer",
648
+ title: "OldTitle",
649
+ allOf: [{ title: "NewTitle", minimum: 0 }]
650
+ }
651
+ expect(flattenSimpleAllOf(input)).toStrictEqual({
652
+ type: "integer",
653
+ title: "NewTitle",
654
+ minimum: 0
655
+ })
656
+ })
657
+ })
658
+
659
+ describe("Post-processing integration — real Effect Schema types", () => {
660
+ it("PositiveInt — allOf flattened, no wrapping", () => {
661
+ const doc = specialJsonSchemaDocument(AppSchema.PositiveInt)
662
+ expect(doc.definitions["PositiveInt"]).toStrictEqual({
663
+ type: "integer",
664
+ exclusiveMinimum: 0,
665
+ title: "PositiveInt"
666
+ })
667
+ })
668
+
669
+ it("NonEmptyString255 — multiple allOf constraints merged", () => {
670
+ const doc = specialJsonSchemaDocument(AppSchema.NonEmptyString255)
671
+ expect(doc.definitions["NonEmptyString255"]).toStrictEqual({
672
+ type: "string",
673
+ minLength: 1,
674
+ maxLength: 255,
675
+ title: "NonEmptyString255"
676
+ })
677
+ })
678
+
679
+ it("NullOr(NonEmptyString64k) — null preserved in anyOf, allOf flattened in definition", () => {
680
+ const schema = S.Struct({ note: S.NullOr(AppSchema.NonEmptyString64k) })
681
+ const doc = specialJsonSchemaDocument(schema)
682
+
683
+ // null variant preserved (correct JSON Schema for NullOr)
684
+ expect(doc.schema).toStrictEqual({
685
+ type: "object",
686
+ properties: {
687
+ note: {
688
+ anyOf: [
689
+ { $ref: "#/$defs/NonEmptyString64k" },
690
+ { type: "null" }
691
+ ]
692
+ }
693
+ },
694
+ required: ["note"],
695
+ additionalProperties: false
696
+ })
697
+
698
+ // allOf flattened in the referenced definition
699
+ expect(doc.definitions["NonEmptyString64k"]).toStrictEqual({
700
+ type: "string",
701
+ minLength: 1,
702
+ maxLength: 65536,
703
+ title: "NonEmptyString64k"
704
+ })
705
+ })
706
+
707
+ it("NonNegativeInt — allOf flattened", () => {
708
+ const doc = specialJsonSchemaDocument(AppSchema.NonNegativeInt)
709
+ expect(doc.definitions["NonNegativeInt"]).toStrictEqual({
710
+ type: "integer",
711
+ minimum: 0,
712
+ title: "NonNegativeInt"
713
+ })
714
+ })
715
+
716
+ it("NullOr union preserves null and non-null members in anyOf", () => {
717
+ const A = S.String.annotate({ identifier: "A", title: "A" })
718
+ const B = S.Boolean.annotate({ identifier: "B", title: "B" })
719
+ const schema = S.Struct({
720
+ value: S.NullOr(S.Union([A, B]))
721
+ })
722
+ const doc = specialJsonSchemaDocument(schema)
723
+ const valueProp = (doc.schema as Record<string, any>)["properties"]["value"]
724
+
725
+ expect(valueProp).toStrictEqual({
726
+ anyOf: [
727
+ { anyOf: [{ $ref: "#/$defs/A" }, { $ref: "#/$defs/B" }] },
728
+ { type: "null" }
729
+ ]
730
+ })
731
+ })
732
+ })