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.
- package/CHANGELOG.md +243 -0
- package/dist/Config.d.ts +7 -0
- package/dist/Config.d.ts.map +1 -0
- package/dist/Config.js +6 -0
- package/dist/ConfigProvider.d.ts +39 -0
- package/dist/ConfigProvider.d.ts.map +1 -0
- package/dist/ConfigProvider.js +42 -0
- package/dist/Effect.d.ts.map +1 -1
- package/dist/Effect.js +3 -2
- package/dist/Operations.d.ts +51 -15
- package/dist/Operations.d.ts.map +1 -1
- package/dist/Pure.d.ts.map +1 -1
- package/dist/Pure.js +11 -11
- package/dist/Schema/Class.d.ts +39 -1
- package/dist/Schema/Class.d.ts.map +1 -1
- package/dist/Schema/Class.js +89 -12
- package/dist/Schema/SpecialJsonSchema.d.ts +40 -0
- package/dist/Schema/SpecialJsonSchema.d.ts.map +1 -0
- package/dist/Schema/SpecialJsonSchema.js +199 -0
- package/dist/Schema/SpecialOpenApi.d.ts +30 -0
- package/dist/Schema/SpecialOpenApi.d.ts.map +1 -0
- package/dist/Schema/SpecialOpenApi.js +120 -0
- package/dist/Schema/brand.d.ts +8 -5
- package/dist/Schema/brand.d.ts.map +1 -1
- package/dist/Schema/brand.js +1 -1
- package/dist/Schema/email.d.ts.map +1 -1
- package/dist/Schema/email.js +4 -3
- package/dist/Schema/ext.d.ts +177 -44
- package/dist/Schema/ext.d.ts.map +1 -1
- package/dist/Schema/ext.js +144 -35
- package/dist/Schema/moreStrings.d.ts.map +1 -1
- package/dist/Schema/moreStrings.js +6 -4
- package/dist/Schema/numbers.d.ts +8 -8
- package/dist/Schema/numbers.js +2 -2
- package/dist/Schema/phoneNumber.d.ts.map +1 -1
- package/dist/Schema/phoneNumber.js +3 -2
- package/dist/Schema.d.ts +21 -54
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +43 -64
- package/dist/ServiceMap.d.ts +3 -3
- package/dist/ServiceMap.d.ts.map +1 -1
- package/dist/ServiceMap.js +1 -1
- package/dist/client/apiClientFactory.d.ts +1 -1
- package/dist/client/apiClientFactory.d.ts.map +1 -1
- package/dist/client/apiClientFactory.js +8 -9
- package/dist/client/errors.d.ts +8 -0
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +34 -10
- package/dist/client/makeClient.d.ts +13 -12
- package/dist/client/makeClient.d.ts.map +1 -1
- package/dist/client/makeClient.js +7 -15
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +5 -5
- package/dist/ids.d.ts +1 -1
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/utils.d.ts +18 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +24 -5
- package/package.json +24 -12
- package/src/Config.ts +14 -0
- package/src/ConfigProvider.ts +48 -0
- package/src/Effect.ts +3 -2
- package/src/Pure.ts +12 -13
- package/src/Schema/Class.ts +114 -16
- package/src/Schema/SpecialJsonSchema.ts +216 -0
- package/src/Schema/SpecialOpenApi.ts +126 -0
- package/src/Schema/brand.ts +13 -7
- package/src/Schema/email.ts +4 -2
- package/src/Schema/ext.ts +213 -56
- package/src/Schema/moreStrings.ts +10 -6
- package/src/Schema/numbers.ts +2 -2
- package/src/Schema/phoneNumber.ts +3 -1
- package/src/Schema.ts +79 -103
- package/src/ServiceMap.ts +7 -6
- package/src/client/apiClientFactory.ts +12 -15
- package/src/client/errors.ts +45 -12
- package/src/client/makeClient.ts +33 -26
- package/src/http/Request.ts +7 -4
- package/src/ids.ts +1 -1
- package/src/index.ts +2 -1
- package/src/utils.ts +26 -4
- package/test/dist/moreStrings.test.d.ts.map +1 -0
- package/test/dist/rpc.test.d.ts.map +1 -1
- package/test/dist/special.test.d.ts.map +1 -0
- package/test/moreStrings.test.ts +17 -0
- package/test/rpc.test.ts +26 -5
- package/test/schema.test.ts +292 -1
- package/test/special.test.ts +525 -0
- package/test/utils.test.ts +1 -1
- package/tsconfig.base.json +0 -1
- package/tsconfig.json +0 -1
- package/dist/Struct.d.ts +0 -44
- package/dist/Struct.d.ts.map +0 -1
- package/dist/Struct.js +0 -29
- 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>] =
|
|
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>()
|
|
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>()
|
|
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>()
|
|
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>()
|
|
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>()
|
|
154
|
-
.
|
|
155
|
-
({ env: _ }
|
|
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 }
|
|
159
|
+
({ log, state }) => tuple(log, Result.succeed(tuple(state, x)))
|
|
161
160
|
)
|
|
162
161
|
))
|
|
163
|
-
.pipe(Effect.catch((err: any) =>
|
|
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>()
|
|
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>()
|
|
212
|
+
(castTag<W, S3, S2>()).useSync((_) => _),
|
|
214
213
|
(_: any) =>
|
|
215
214
|
Effect.map(mod(_.env.state), ([s, a]: any) => {
|
|
216
|
-
_.env.state = s
|
|
215
|
+
_.env.state = s
|
|
217
216
|
return a
|
|
218
217
|
})
|
|
219
218
|
) as any
|
package/src/Schema/Class.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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<"
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|