effect-app 4.0.0-beta.5 → 4.0.0-beta.52

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 (99) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/Config.d.ts +7 -0
  3. package/dist/Config.d.ts.map +1 -0
  4. package/dist/Config.js +6 -0
  5. package/dist/ConfigProvider.d.ts +39 -0
  6. package/dist/ConfigProvider.d.ts.map +1 -0
  7. package/dist/ConfigProvider.js +42 -0
  8. package/dist/Effect.d.ts.map +1 -1
  9. package/dist/Effect.js +3 -2
  10. package/dist/Operations.d.ts +51 -15
  11. package/dist/Operations.d.ts.map +1 -1
  12. package/dist/Pure.d.ts.map +1 -1
  13. package/dist/Pure.js +11 -11
  14. package/dist/Schema/Class.d.ts +39 -1
  15. package/dist/Schema/Class.d.ts.map +1 -1
  16. package/dist/Schema/Class.js +89 -12
  17. package/dist/Schema/SpecialJsonSchema.d.ts +40 -0
  18. package/dist/Schema/SpecialJsonSchema.d.ts.map +1 -0
  19. package/dist/Schema/SpecialJsonSchema.js +199 -0
  20. package/dist/Schema/SpecialOpenApi.d.ts +30 -0
  21. package/dist/Schema/SpecialOpenApi.d.ts.map +1 -0
  22. package/dist/Schema/SpecialOpenApi.js +120 -0
  23. package/dist/Schema/brand.d.ts +8 -5
  24. package/dist/Schema/brand.d.ts.map +1 -1
  25. package/dist/Schema/brand.js +1 -1
  26. package/dist/Schema/email.d.ts.map +1 -1
  27. package/dist/Schema/email.js +4 -3
  28. package/dist/Schema/ext.d.ts +177 -44
  29. package/dist/Schema/ext.d.ts.map +1 -1
  30. package/dist/Schema/ext.js +144 -35
  31. package/dist/Schema/moreStrings.d.ts.map +1 -1
  32. package/dist/Schema/moreStrings.js +6 -4
  33. package/dist/Schema/numbers.d.ts +8 -8
  34. package/dist/Schema/numbers.js +2 -2
  35. package/dist/Schema/phoneNumber.d.ts.map +1 -1
  36. package/dist/Schema/phoneNumber.js +3 -2
  37. package/dist/Schema.d.ts +21 -54
  38. package/dist/Schema.d.ts.map +1 -1
  39. package/dist/Schema.js +43 -64
  40. package/dist/ServiceMap.d.ts +3 -3
  41. package/dist/ServiceMap.d.ts.map +1 -1
  42. package/dist/ServiceMap.js +1 -1
  43. package/dist/client/apiClientFactory.d.ts +1 -1
  44. package/dist/client/apiClientFactory.d.ts.map +1 -1
  45. package/dist/client/apiClientFactory.js +8 -9
  46. package/dist/client/errors.d.ts +8 -0
  47. package/dist/client/errors.d.ts.map +1 -1
  48. package/dist/client/errors.js +34 -10
  49. package/dist/client/makeClient.d.ts +13 -12
  50. package/dist/client/makeClient.d.ts.map +1 -1
  51. package/dist/client/makeClient.js +7 -15
  52. package/dist/http/Request.d.ts.map +1 -1
  53. package/dist/http/Request.js +5 -5
  54. package/dist/ids.d.ts +1 -1
  55. package/dist/ids.d.ts.map +1 -1
  56. package/dist/ids.js +1 -1
  57. package/dist/index.d.ts +2 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3 -2
  60. package/dist/utils.d.ts +18 -0
  61. package/dist/utils.d.ts.map +1 -1
  62. package/dist/utils.js +24 -5
  63. package/package.json +24 -12
  64. package/src/Config.ts +14 -0
  65. package/src/ConfigProvider.ts +48 -0
  66. package/src/Effect.ts +3 -2
  67. package/src/Pure.ts +12 -13
  68. package/src/Schema/Class.ts +114 -16
  69. package/src/Schema/SpecialJsonSchema.ts +216 -0
  70. package/src/Schema/SpecialOpenApi.ts +126 -0
  71. package/src/Schema/brand.ts +13 -7
  72. package/src/Schema/email.ts +4 -2
  73. package/src/Schema/ext.ts +213 -56
  74. package/src/Schema/moreStrings.ts +10 -6
  75. package/src/Schema/numbers.ts +2 -2
  76. package/src/Schema/phoneNumber.ts +3 -1
  77. package/src/Schema.ts +79 -103
  78. package/src/ServiceMap.ts +7 -6
  79. package/src/client/apiClientFactory.ts +12 -15
  80. package/src/client/errors.ts +45 -12
  81. package/src/client/makeClient.ts +33 -26
  82. package/src/http/Request.ts +7 -4
  83. package/src/ids.ts +1 -1
  84. package/src/index.ts +2 -1
  85. package/src/utils.ts +26 -4
  86. package/test/dist/moreStrings.test.d.ts.map +1 -0
  87. package/test/dist/rpc.test.d.ts.map +1 -1
  88. package/test/dist/special.test.d.ts.map +1 -0
  89. package/test/moreStrings.test.ts +17 -0
  90. package/test/rpc.test.ts +26 -5
  91. package/test/schema.test.ts +292 -1
  92. package/test/special.test.ts +525 -0
  93. package/test/utils.test.ts +1 -1
  94. package/tsconfig.base.json +0 -1
  95. package/tsconfig.json +0 -1
  96. package/dist/Struct.d.ts +0 -44
  97. package/dist/Struct.d.ts.map +0 -1
  98. package/dist/Struct.js +0 -29
  99. package/src/Struct.ts +0 -54
@@ -0,0 +1,48 @@
1
+ import { type ConfigProvider, make, type Path } from "effect/ConfigProvider"
2
+ import { dual } from "effect/Function"
3
+
4
+ /**
5
+ * Scopes a provider so that all lookups are prefixed with the given path
6
+ * segments.
7
+ *
8
+ * When to use:
9
+ * - Namespacing config under a prefix like `"app"` or `"database"`.
10
+ * - Reusing the same provider shape for multiple sub-configs.
11
+ *
12
+ * Accepts a single string or a full `Path` array. The prefix is prepended
13
+ * *after* any `mapInput` transformation runs, so ordering matters when
14
+ * composing with {@link mapInput} or {@link constantCase}.
15
+ *
16
+ * Supports both data-last and data-first calling conventions.
17
+ *
18
+ * **Example** (Nesting under a prefix)
19
+ *
20
+ * ```ts
21
+ * import { ConfigProvider } from "effect"
22
+ *
23
+ * const provider = ConfigProvider.fromEnv({
24
+ * env: { APP_HOST: "localhost", APP_PORT: "3000" }
25
+ * })
26
+ *
27
+ * // Lookups for ["HOST"] now resolve to ["APP", "HOST"]
28
+ * const scoped = ConfigProvider.nested(provider, "APP")
29
+ * ```
30
+ *
31
+ * @see {@link mapInput} – for arbitrary path transformations
32
+ *
33
+ * @category Combinators
34
+ * @since 4.0.0
35
+ */
36
+ export const nested: {
37
+ (prefix: string | Path): (self: ConfigProvider) => ConfigProvider
38
+ (self: ConfigProvider, prefix: string | Path): ConfigProvider
39
+ } = dual(
40
+ 2,
41
+ (self: ConfigProvider, prefix: string | Path): ConfigProvider => {
42
+ let path: Path = typeof prefix === "string" ? [prefix] : prefix
43
+ if (self.mapInput) path = self.mapInput(path)
44
+ return make(self.get, self.mapInput, self.prefix ? [...self.prefix, ...path] : path)
45
+ }
46
+ )
47
+
48
+ export * from "effect/ConfigProvider"
package/src/Effect.ts CHANGED
@@ -153,8 +153,9 @@ export function allLower<T extends Record<string, ServiceMap.Service<any, any> |
153
153
  ) {
154
154
  return Effect.all(
155
155
  typedKeysOf(services).reduce((prev, cur) => {
156
- const svc = services[cur]
157
- prev[((cur as string)[0]!.toLowerCase() + (cur as string).slice(1)) as unknown as LowerFirst<typeof cur>] = svc // "_id" in svc && svc._id === TagTypeId ? svc : svc
156
+ const svc = services[cur]!
157
+ prev[((cur as string)[0]!.toLowerCase() + (cur as string).slice(1)) as unknown as LowerFirst<typeof cur>] =
158
+ "asEffect" in svc ? svc.asEffect() : svc
158
159
  return prev
159
160
  }, {} as any),
160
161
  { concurrency: "inherit" }
package/src/Pure.ts CHANGED
@@ -117,11 +117,11 @@ export interface PureEnvEnv<W, S, S2> extends ServiceTagged<typeof PureEnvEnv> {
117
117
  }
118
118
 
119
119
  export function get<S>(): Pure<never, S, S, never, never, S> {
120
- return (castTag<never, S, S>() as any).use((_: any) => _.env.state)
120
+ return (castTag<never, S, S>()).useSync((_) => _.env.state)
121
121
  }
122
122
 
123
123
  export function set<S>(s: S): Pure<never, S, S, never, never, void> {
124
- return (castTag<never, S, S>() as any).use((_: any) => {
124
+ return (castTag<never, S, S>()).useSync((_) => {
125
125
  _.env.state = s
126
126
  })
127
127
  }
@@ -129,13 +129,13 @@ export function set<S>(s: S): Pure<never, S, S, never, never, void> {
129
129
  export type PureLogT<W> = Pure<W, unknown, never, never, never, void>
130
130
 
131
131
  export function log<W>(w: W): PureLogT<W> {
132
- return (castTag<W, unknown, never>() as any).use((_: any) => {
132
+ return (castTag<W, unknown, never>()).useSync((_) => {
133
133
  _.env.log = Chunk.append(_.env.log, w)
134
134
  })
135
135
  }
136
136
 
137
137
  export function logMany<W>(w: Iterable<W>): PureLogT<W> {
138
- return (castTag<W, unknown, never>() as any).use((_: any) => {
138
+ return (castTag<W, unknown, never>()).useSync((_) => {
139
139
  _.env.log = Chunk.appendAll(_.env.log, Chunk.fromIterable(w))
140
140
  })
141
141
  }
@@ -150,17 +150,16 @@ export function runAll<R, E, A, W3, S1, S3, S4 extends S1>(
150
150
  > {
151
151
  const a = Effect
152
152
  .flatMap(self, (x) =>
153
- (castTag<W3, S1, S3>() as any)
154
- .use(
155
- ({ env: _ }: any) => Effect.sync(() => ({ log: _.log, state: _.state }))
153
+ (castTag<W3, S1, S3>())
154
+ .useSync(
155
+ ({ env: _ }) => ({ log: _.log, state: _.state })
156
156
  )
157
157
  .pipe(
158
- Effect.flatMap((_: any) => Effect.succeed(_)),
159
158
  Effect.map(
160
- ({ log, state }: any) => tuple(log, Result.succeed(tuple(state, x)))
159
+ ({ log, state }) => tuple(log, Result.succeed(tuple(state, x)))
161
160
  )
162
161
  ))
163
- .pipe(Effect.catch((err: any) => (tagg as any).use((env: any) => tuple(env.env.log, Result.fail(err)))))
162
+ .pipe(Effect.catch((err: any) => tagg.useSync((env) => tuple(env.env.log, Result.fail(err)))))
164
163
  return Effect.provide(a, Layer.succeed(tagg, { env: makePureEnv<W3, S3, S4>(s) as any }) as any) as any
165
164
  }
166
165
 
@@ -198,7 +197,7 @@ export function runA<R, E, A, W3, S1, S3, S4 extends S1>(
198
197
  export function modify<S2, A, S3>(
199
198
  mod: (s: S2) => readonly [S3, A]
200
199
  ): Effect.Effect<A, never, { env: PureEnv<never, S2, S3> }> {
201
- return (castTag<never, S3, S2>() as any).use((_: any) => {
200
+ return (castTag<never, S3, S2>()).useSync((_) => {
202
201
  const [s, a] = mod(_.env.state)
203
202
  _.env.state = s as any
204
203
  return a
@@ -210,10 +209,10 @@ export function modifyM<W, R, E, A, S2, S3>(
210
209
  ): Effect.Effect<A, E, FixEnv<R, W, S2, S3>> {
211
210
  // return serviceWithEffect(_ => Ref.modifyM_(_.state, mod))
212
211
  return Effect.flatMap(
213
- (castTag<W, S3, S2>() as any).use((_: any) => _),
212
+ (castTag<W, S3, S2>()).useSync((_) => _),
214
213
  (_: any) =>
215
214
  Effect.map(mod(_.env.state), ([s, a]: any) => {
216
- _.env.state = s as any
215
+ _.env.state = s
217
216
  return a
218
217
  })
219
218
  ) as any
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { pipe, Struct as Struct2 } from "effect"
2
+ import { Effect, Option, pipe, Schema, SchemaAST, SchemaIssue, Struct as Struct2 } from "effect"
3
3
  import type { Struct } from "effect/Schema"
4
4
  import * as S from "effect/Schema"
5
5
 
@@ -13,9 +13,6 @@ type MissingSelfGeneric<Usage extends string, Params extends string = ""> =
13
13
  `Missing \`Self\` generic - use \`class Self extends ${Usage}<Self>()(${Params}{ ... })\``
14
14
 
15
15
  export interface PropsExtensions<Fields> {
16
- // include: <NewProps extends S.Struct.Fields>(
17
- // fnc: (fields: Fields) => NewProps
18
- // ) => NewProps
19
16
  pick: <P extends keyof Fields>(...keys: readonly P[]) => Pick<Fields, P>
20
17
  omit: <P extends keyof Fields>(...keys: readonly P[]) => Omit<Fields, P>
21
18
  }
@@ -26,6 +23,57 @@ type HasFields<Fields extends Struct.Fields> = {
26
23
  readonly from: HasFields<Fields>
27
24
  }
28
25
 
26
+ export type Class<Self, S extends S.Top & { readonly fields: Struct.Fields }, Inherited> = S.Class<Self, S, Inherited>
27
+
28
+ /**
29
+ * Build a modified Declaration that accepts struct-matching values during
30
+ * encoding, given the original Declaration and the class's fields.
31
+ */
32
+ function makeRelaxedDeclaration(
33
+ ast: SchemaAST.Declaration,
34
+ fields: Schema.Struct.Fields,
35
+ cls: any
36
+ ): SchemaAST.Declaration {
37
+ const structSchema = Schema.Struct(fields)
38
+ const isStructValue = Schema.is(structSchema)
39
+ return new SchemaAST.Declaration(
40
+ ast.typeParameters,
41
+ () => (input: unknown, self: SchemaAST.Declaration) => {
42
+ if (input instanceof cls || isStructValue(input)) {
43
+ return Effect.succeed(input)
44
+ }
45
+ return Effect.fail(new SchemaIssue.InvalidType(self, Option.some(input)))
46
+ },
47
+ ast.annotations,
48
+ ast.checks,
49
+ ast.encoding,
50
+ ast.context
51
+ )
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Class — like Schema.Class but with relaxed encoding
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Like `Schema.Class`, but the resulting class accepts plain objects matching
60
+ * the struct schema during encoding — not only `instanceof` or type-id
61
+ * checks.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * import { Schema } from "effect"
66
+ * import { Class } from "./Class.js"
67
+ *
68
+ * class A extends Class<A>("A")({ a: Schema.String }) {}
69
+ *
70
+ * // Construction works as normal:
71
+ * new A({ a: "hello" })
72
+ *
73
+ * // Encoding accepts plain objects:
74
+ * Schema.encodeUnknownSync(A)({ a: "hello" }) // { a: "hello" }
75
+ * ```
76
+ */
29
77
  export const Class: <Self = never>(identifier: string) => <Fields extends S.Struct.Fields>(
30
78
  fieldsOr: Fields | HasFields<Fields>,
31
79
  annotations?: ClassAnnotations<Self>
@@ -35,38 +83,84 @@ export const Class: <Self = never>(identifier: string) => <Fields extends S.Stru
35
83
  S.Struct<Fields>,
36
84
  {}
37
85
  > = (identifier) => (fields, annotations) => {
38
- const cls = S.Class as any
39
- return class extends cls(identifier)(fields, annotations) {
40
- constructor(a: any, b = true) {
41
- super(a, b)
86
+ // Build the original Schema.Class
87
+ const Base = (S.Class as any)(identifier)(fields, annotations)
88
+ // Get the original ast getter from the base class
89
+ const originalAstDescriptor = Object.getOwnPropertyDescriptor(Base, "ast")!
90
+
91
+ // Cache per-class to avoid recomputing
92
+ const astCache = new WeakMap<any, SchemaAST.Declaration>()
93
+
94
+ return class extends Base {
95
+ static get ast(): SchemaAST.Declaration {
96
+ let cached = astCache.get(this)
97
+ if (cached !== undefined) return cached
98
+ // Call the original getter with `this` bound to the actual user class,
99
+ // so getClassSchema(this) creates a schema that uses `new this(...)`.
100
+ const originalAst = originalAstDescriptor.get!.call(this) as SchemaAST.Declaration
101
+ cached = makeRelaxedDeclaration(originalAst, Base.fields, this)
102
+ astCache.set(this, cached)
103
+ return cached
42
104
  }
43
- // static readonly include = include(fields)
44
105
  static readonly pick = (...selection: any[]) => pipe(this["fields"], Struct2.pick(selection))
45
106
  static readonly omit = (...selection: any[]) => pipe(this["fields"], Struct2.omit(selection))
46
107
  } as any
47
108
  }
48
109
 
49
- export const TaggedClass: <Self = never>(identifier?: string) => <Tag extends string, Fields extends S.Struct.Fields>(
110
+ // ---------------------------------------------------------------------------
111
+ // TaggedClass — like Schema.TaggedClass but with relaxed encoding
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Like `Schema.TaggedClass`, but the resulting class accepts plain objects
116
+ * matching the struct schema during encoding.
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * import { Schema } from "effect"
121
+ * import { TaggedClass } from "./Class.js"
122
+ *
123
+ * class Circle extends TaggedClass<Circle>()("Circle", {
124
+ * radius: Schema.Number
125
+ * }) {}
126
+ *
127
+ * Schema.encodeUnknownSync(Circle)({ _tag: "Circle", radius: 5 })
128
+ * ```
129
+ */
130
+ export const TaggedClass: <Self = never>(
131
+ identifier?: string
132
+ ) => <Tag extends string, Fields extends S.Struct.Fields>(
50
133
  tag: Tag,
51
134
  fieldsOr: Fields | HasFields<Fields>,
52
135
  annotations?: ClassAnnotations<Self>
53
- ) => [Self] extends [never] ? MissingSelfGeneric<"Class">
136
+ ) => [Self] extends [never] ? MissingSelfGeneric<"TaggedClass">
54
137
  : EnhancedClass<
55
138
  Self,
56
139
  S.Struct<{ readonly _tag: S.tag<Tag> } & Fields>,
57
140
  {}
58
141
  > = (identifier) => (tag, fields, annotations) => {
59
- const cls = S.TaggedClass as any
60
- return class extends cls(identifier)(tag, fields, annotations) {
61
- constructor(a: any, b = true) {
62
- super(a, b)
142
+ const Base = (S.TaggedClass as any)(identifier)(tag, fields, annotations)
143
+ const originalAstDescriptor = Object.getOwnPropertyDescriptor(Base, "ast")!
144
+ const astCache = new WeakMap<any, SchemaAST.Declaration>()
145
+
146
+ return class extends Base {
147
+ static get ast(): SchemaAST.Declaration {
148
+ let cached = astCache.get(this)
149
+ if (cached !== undefined) return cached
150
+ const originalAst = originalAstDescriptor.get!.call(this) as SchemaAST.Declaration
151
+ cached = makeRelaxedDeclaration(originalAst, Base.fields, this)
152
+ astCache.set(this, cached)
153
+ return cached
63
154
  }
64
- // static readonly include = include(fields)
65
155
  static readonly pick = (...selection: any[]) => pipe(this["fields"], Struct2.pick(selection))
66
156
  static readonly omit = (...selection: any[]) => pipe(this["fields"], Struct2.omit(selection))
67
157
  } as any
68
158
  }
69
159
 
160
+ // ---------------------------------------------------------------------------
161
+ // ExtendedClass — like Class but with extra type parameter for hierarchies
162
+ // ---------------------------------------------------------------------------
163
+
70
164
  export const ExtendedClass: <Self, _SelfFrom>(identifier: string) => <Fields extends S.Struct.Fields>(
71
165
  fieldsOr: Fields | HasFields<Fields>,
72
166
  annotations?: ClassAnnotations<Self>
@@ -76,6 +170,10 @@ export const ExtendedClass: <Self, _SelfFrom>(identifier: string) => <Fields ext
76
170
  {}
77
171
  > = Class as any
78
172
 
173
+ // ---------------------------------------------------------------------------
174
+ // ExtendedTaggedClass — like TaggedClass but with extra type parameter for hierarchies
175
+ // ---------------------------------------------------------------------------
176
+
79
177
  export interface EnhancedTaggedClass<Self, Tag extends string, Fields extends Struct.Fields, SelfFrom>
80
178
  extends
81
179
  EnhancedClass<
@@ -0,0 +1,216 @@
1
+ /**
2
+ * SpecialJsonSchema — A variant of Schema.toJsonSchemaDocument that deduplicates
3
+ * references sharing the same identifier when they produce identical
4
+ * representations (based on a string fingerprint).
5
+ *
6
+ * Without this, two different AST nodes that have the same identifier and
7
+ * resolve to the same JSON Schema representation can end up as separate $defs
8
+ * entries (e.g. "X" and "X1"). This converter collapses them into one.
9
+ */
10
+ import { Formatter, type JsonSchema, type Schema, SchemaRepresentation } from "effect"
11
+
12
+ /**
13
+ * Converts a schema to a JSON Schema Document (draft-2020-12), with an
14
+ * extra deduplication pass that collapses references sharing the same
15
+ * base identifier when their representations are identical.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { Schema, SchemaGetter, Option, Predicate } from "effect"
20
+ * import { specialJsonSchemaDocument } from "./SpecialJsonSchema.js"
21
+ *
22
+ * const X = Schema.String.annotate({ title: "X", identifier: "X" })
23
+ *
24
+ * const s = Schema.Struct({
25
+ * a: Schema.NullOr(X).pipe(
26
+ * Schema.encodeTo(Schema.optionalKey(X), {
27
+ * decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
28
+ * encode: SchemaGetter.transformOptional(Option.filter(Predicate.isNotNull))
29
+ * })
30
+ * ),
31
+ * b: Schema.NullOr(X),
32
+ * c: X
33
+ * })
34
+ *
35
+ * // Without dedup: $defs would contain both "X" and "X1" (identical).
36
+ * // With specialJsonSchemaDocument: only "X" is emitted.
37
+ * const doc = specialJsonSchemaDocument(s)
38
+ * ```
39
+ */
40
+ export function specialJsonSchemaDocument(
41
+ schema: Schema.Top,
42
+ options?: Schema.ToJsonSchemaOptions
43
+ ): JsonSchema.Document<"draft-2020-12"> {
44
+ const doc = SchemaRepresentation.fromAST(schema.ast)
45
+ const deduped = deduplicateReferences(doc)
46
+ const jd = SchemaRepresentation.toJsonSchemaDocument(deduped, options)
47
+ return {
48
+ dialect: "draft-2020-12",
49
+ schema: jd.schema,
50
+ definitions: jd.definitions
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Deduplicates references in a Document: when multiple $ref keys share
56
+ * the same base identifier (e.g. "X" and "X1") and their representations
57
+ * are identical (by string fingerprint), the duplicates are collapsed into
58
+ * the first entry found for that identifier.
59
+ */
60
+ function deduplicateReferences(
61
+ doc: SchemaRepresentation.Document
62
+ ): SchemaRepresentation.Document {
63
+ const refs = doc.references
64
+ const refKeys = Object.keys(refs)
65
+ if (refKeys.length === 0) return doc
66
+
67
+ // Group references by base identifier (strip trailing digits added by gen())
68
+ const identifierGroups = new Map<string, Array<{ key: string; fingerprint: string }>>()
69
+ for (const key of refKeys) {
70
+ const base = getBaseIdentifier(key)
71
+ const fingerprint = Formatter.format(refs[key])
72
+ const group = identifierGroups.get(base)
73
+ if (group === undefined) {
74
+ identifierGroups.set(base, [{ key, fingerprint }])
75
+ } else {
76
+ group.push({ key, fingerprint })
77
+ }
78
+ }
79
+
80
+ // Build a mapping from duplicate keys to their canonical key
81
+ const remapping = new Map<string, string>()
82
+ for (const [, group] of identifierGroups) {
83
+ const seen = new Map<string, string>() // fingerprint -> canonical key
84
+ for (const entry of group) {
85
+ const canonical = seen.get(entry.fingerprint)
86
+ if (canonical !== undefined) {
87
+ remapping.set(entry.key, canonical)
88
+ } else {
89
+ seen.set(entry.fingerprint, entry.key)
90
+ }
91
+ }
92
+ }
93
+
94
+ if (remapping.size === 0) return doc
95
+
96
+ // Build new references, excluding duplicates
97
+ const newRefs: Record<string, SchemaRepresentation.Representation> = {}
98
+ for (const key of refKeys) {
99
+ if (!remapping.has(key)) {
100
+ newRefs[key] = refs[key]!
101
+ }
102
+ }
103
+
104
+ // Rewrite $ref pointers throughout the document
105
+ const newRepresentation = rewriteRefs(doc.representation, remapping)
106
+ const rewrittenRefs: Record<string, SchemaRepresentation.Representation> = {}
107
+ for (const [key, rep] of Object.entries(newRefs)) {
108
+ rewrittenRefs[key] = rewriteRefs(rep, remapping)
109
+ }
110
+
111
+ return { representation: newRepresentation, references: rewrittenRefs }
112
+ }
113
+
114
+ /**
115
+ * Extracts the base identifier from a reference key by stripping trailing
116
+ * digits appended by the gen() function during fromASTs.
117
+ * E.g. "X1" -> "X", "X" -> "X", "MyType2" -> "MyType"
118
+ */
119
+ function getBaseIdentifier(key: string): string {
120
+ const match = key.match(/^(.+?)(\d+)$/)
121
+ return match ? match[1]! : key
122
+ }
123
+
124
+ /**
125
+ * Recursively rewrites $ref pointers in a Representation tree.
126
+ */
127
+ function rewriteRefs(
128
+ rep: SchemaRepresentation.Representation,
129
+ remapping: Map<string, string>
130
+ ): SchemaRepresentation.Representation {
131
+ switch (rep._tag) {
132
+ case "Reference": {
133
+ const target = remapping.get(rep.$ref)
134
+ return target !== undefined ? { ...rep, $ref: target } : rep
135
+ }
136
+ case "Declaration":
137
+ return {
138
+ ...rep,
139
+ typeParameters: rep.typeParameters.map((tp) => rewriteRefs(tp, remapping)),
140
+ encodedSchema: rewriteRefs(rep.encodedSchema, remapping)
141
+ }
142
+ case "Suspend":
143
+ return {
144
+ ...rep,
145
+ thunk: rewriteRefs(rep.thunk, remapping)
146
+ }
147
+ case "Arrays":
148
+ return {
149
+ ...rep,
150
+ elements: rep.elements.map((e) => ({ ...e, type: rewriteRefs(e.type, remapping) })),
151
+ rest: rep.rest.map((r) => rewriteRefs(r, remapping))
152
+ }
153
+ case "Objects":
154
+ return {
155
+ ...rep,
156
+ propertySignatures: rep.propertySignatures.map((ps) => ({
157
+ ...ps,
158
+ type: rewriteRefs(ps.type, remapping)
159
+ })),
160
+ indexSignatures: rep.indexSignatures.map((is) => ({
161
+ ...is,
162
+ parameter: rewriteRefs(is.parameter, remapping),
163
+ type: rewriteRefs(is.type, remapping)
164
+ })),
165
+ checks: rewriteChecks(rep.checks, remapping)
166
+ }
167
+ case "Union":
168
+ return {
169
+ ...rep,
170
+ types: rep.types.map((t) => rewriteRefs(t, remapping))
171
+ }
172
+ case "TemplateLiteral":
173
+ return {
174
+ ...rep,
175
+ parts: rep.parts.map((p) => rewriteRefs(p, remapping))
176
+ }
177
+ case "String": {
178
+ if (rep.contentSchema !== undefined) {
179
+ return {
180
+ ...rep,
181
+ contentSchema: rewriteRefs(rep.contentSchema, remapping)
182
+ }
183
+ }
184
+ return rep
185
+ }
186
+ default:
187
+ // Leaf nodes: Null, Undefined, Void, Never, Unknown, Any, Boolean,
188
+ // Symbol, Number, BigInt, Literal, UniqueSymbol, ObjectKeyword, Enum
189
+ return rep
190
+ }
191
+ }
192
+
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ function rewriteChecks<M>(
195
+ checks: ReadonlyArray<SchemaRepresentation.Check<M>>,
196
+ remapping: Map<string, string>
197
+ ): ReadonlyArray<SchemaRepresentation.Check<M>> {
198
+ return checks.map((c) => {
199
+ switch (c._tag) {
200
+ case "Filter": {
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ const meta = c.meta as any
203
+ if (meta && meta._tag === "isPropertyNames" && meta.propertyNames) {
204
+ return {
205
+ ...c,
206
+ meta: { ...meta, propertyNames: rewriteRefs(meta.propertyNames, remapping) }
207
+ }
208
+ }
209
+ return c
210
+ }
211
+ case "FilterGroup":
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ return { ...c, checks: rewriteChecks(c.checks, remapping) as any }
214
+ }
215
+ })
216
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * SpecialOpenApi — Deduplicates `components/schemas` entries in an OpenAPI spec.
3
+ *
4
+ * When `OpenApi.fromApi` generates the spec, different AST nodes sharing the
5
+ * same identifier can produce duplicate entries (e.g. "X" and "X1") in
6
+ * `components.schemas`. This module provides a transform function that
7
+ * collapses those duplicates and rewrites all `$ref` pointers accordingly.
8
+ *
9
+ * Usage with the OpenApi `Transform` annotation:
10
+ *
11
+ * ```ts
12
+ * import { OpenApi } from "effect/unstable"
13
+ * import { deduplicateOpenApiSchemas } from "./SpecialOpenApi.js"
14
+ *
15
+ * const api = HttpApi.make("myApi")
16
+ * .pipe(HttpApi.annotateContext(OpenApi.annotations({ transform: deduplicateOpenApiSchemas })))
17
+ * ```
18
+ */
19
+
20
+ /**
21
+ * Deduplicates `components.schemas` entries in an OpenAPI spec.
22
+ *
23
+ * Entries sharing the same base identifier (e.g. "X" and "X1") whose JSON
24
+ * representations are identical are collapsed into a single canonical entry,
25
+ * and all `$ref` pointers throughout the spec are rewritten to point to
26
+ * the canonical key.
27
+ *
28
+ * Designed to be used as the `transform` option in `OpenApi.annotations`.
29
+ */
30
+ export function deduplicateOpenApiSchemas(
31
+ spec: Record<string, any>
32
+ ): Record<string, any> {
33
+ const components = spec["components"] as Record<string, any> | undefined
34
+ if (!components) return spec
35
+ const schemas = components["schemas"] as Record<string, any> | undefined
36
+ if (!schemas) return spec
37
+
38
+ const keys = Object.keys(schemas)
39
+ if (keys.length === 0) return spec
40
+
41
+ // Group keys by base identifier (strip trailing digits)
42
+ const groups = new Map<string, Array<{ key: string; fingerprint: string }>>()
43
+ for (const key of keys) {
44
+ const base = getBaseIdentifier(key)
45
+ const fingerprint = JSON.stringify(schemas[key])
46
+ const group = groups.get(base)
47
+ if (group === undefined) {
48
+ groups.set(base, [{ key, fingerprint }])
49
+ } else {
50
+ group.push({ key, fingerprint })
51
+ }
52
+ }
53
+
54
+ // Build remapping from duplicate keys to canonical keys
55
+ const remapping = new Map<string, string>()
56
+ for (const [, group] of groups) {
57
+ if (group.length <= 1) continue
58
+ const seen = new Map<string, string>() // fingerprint -> canonical key
59
+ for (const entry of group) {
60
+ const canonical = seen.get(entry.fingerprint)
61
+ if (canonical !== undefined) {
62
+ remapping.set(entry.key, canonical)
63
+ } else {
64
+ seen.set(entry.fingerprint, entry.key)
65
+ }
66
+ }
67
+ }
68
+
69
+ if (remapping.size === 0) return spec
70
+
71
+ // Build new schemas object without duplicates
72
+ const newSchemas: Record<string, any> = {}
73
+ for (const key of keys) {
74
+ if (!remapping.has(key)) {
75
+ newSchemas[key] = schemas[key]
76
+ }
77
+ }
78
+
79
+ // Deep clone the spec, replace schemas, and rewrite all $ref pointers
80
+ const newSpec = structuredClone(spec)
81
+ newSpec["components"]["schemas"] = newSchemas
82
+ rewriteRefs(newSpec, remapping)
83
+
84
+ return newSpec
85
+ }
86
+
87
+ /**
88
+ * Extracts the base identifier from a schema key by stripping trailing
89
+ * digits appended by the gen() function.
90
+ * E.g. "X1" -> "X", "X" -> "X", "MyType2" -> "MyType"
91
+ */
92
+ function getBaseIdentifier(key: string): string {
93
+ const match = key.match(/^(.+?)(\d+)$/)
94
+ return match ? match[1]! : key
95
+ }
96
+
97
+ /**
98
+ * Recursively rewrites `$ref` values in a JSON object tree.
99
+ * Mutates the object in-place (caller should pass a deep clone).
100
+ */
101
+ function rewriteRefs(obj: any, remapping: Map<string, string>): void {
102
+ if (obj === null || typeof obj !== "object") return
103
+
104
+ if (Array.isArray(obj)) {
105
+ for (const item of obj) {
106
+ rewriteRefs(item, remapping)
107
+ }
108
+ return
109
+ }
110
+
111
+ if (typeof obj.$ref === "string") {
112
+ // OpenAPI refs look like "#/components/schemas/X1"
113
+ const prefix = "#/components/schemas/"
114
+ if (obj.$ref.startsWith(prefix)) {
115
+ const refKey = obj.$ref.slice(prefix.length)
116
+ const canonical = remapping.get(refKey)
117
+ if (canonical !== undefined) {
118
+ obj.$ref = prefix + canonical
119
+ }
120
+ }
121
+ }
122
+
123
+ for (const value of Object.values(obj)) {
124
+ rewriteRefs(value, remapping)
125
+ }
126
+ }