@effect/platform-bun 4.0.3 → 4.0.4
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/AGENTS.md +24 -0
- package/CLAUDE.md +24 -0
- package/ai-docs/package.json +2 -2
- package/ai-docs/src/52_graphql/10_basics.ts +111 -0
- package/ai-docs/src/52_graphql/20_testing.ts +45 -0
- package/ai-docs/src/52_graphql/30_batching.ts +116 -0
- package/ai-docs/src/52_graphql/40_aliased-batching.ts +122 -0
- package/ai-docs/src/52_graphql/50_errors-and-retries.ts +74 -0
- package/ai-docs/src/52_graphql/index.md +6 -0
- package/package.json +5 -5
package/AGENTS.md
CHANGED
|
@@ -370,6 +370,30 @@ Build http clients with the `HttpClient` module.
|
|
|
370
370
|
Test handlers through an in-memory typed client with `HttpApiTest`, without
|
|
371
371
|
starting an HTTP server or touching a real database.
|
|
372
372
|
|
|
373
|
+
## Typed GraphQL clients with `effect/graphql`
|
|
374
|
+
|
|
375
|
+
Use the `effect/graphql` modules to call GraphQL APIs with typed
|
|
376
|
+
operations. Run `graphqlgen` from `@effect/graphql-generator` to generate
|
|
377
|
+
operations from `.graphql` documents, then build a client with
|
|
378
|
+
`GraphQLClient.make` and provide a transport from `GraphQLProtocol`.
|
|
379
|
+
|
|
380
|
+
- **[Getting started with GraphQL clients](./ai-docs/src/52_graphql/10_basics.ts)**:
|
|
381
|
+
Define a query, build a typed client service over HTTP with an auth
|
|
382
|
+
middleware, and page through a cursor connection.
|
|
383
|
+
- **[Testing code that uses a GraphQL client](./ai-docs/src/52_graphql/20_testing.ts)**:
|
|
384
|
+
Replace a client service with `GraphQLClient.layerTest`, answering each
|
|
385
|
+
operation from a handler instead of a server.
|
|
386
|
+
- **[Batching lookups with GraphQLResolver](./ai-docs/src/52_graphql/30_batching.ts)**:
|
|
387
|
+
Collect concurrent lookups by id into one `nodes(ids:)` request with
|
|
388
|
+
`GraphQLResolver`, instead of one request per id.
|
|
389
|
+
- **[Batching single-item lookups with aliases](./ai-docs/src/52_graphql/40_aliased-batching.ts)**:
|
|
390
|
+
When an API has no field that takes a list of keys, `GraphQLResolver.aliased`
|
|
391
|
+
repeats a generated operation under aliases, so concurrent lookups share one
|
|
392
|
+
request and keep their generated types.
|
|
393
|
+
- **[Handling GraphQL errors, retries and rate limits](./ai-docs/src/52_graphql/50_errors-and-retries.ts)**:
|
|
394
|
+
Read rate-limit headers in a middleware, tell GraphQL errors apart by their
|
|
395
|
+
fields, and retry timeouts and rate limits with `Effect.retry`.
|
|
396
|
+
|
|
373
397
|
## Working with child processes
|
|
374
398
|
|
|
375
399
|
Use the `effect/process` modules to define child processes and run them with `ChildProcessSpawner`.
|
package/CLAUDE.md
CHANGED
|
@@ -370,6 +370,30 @@ Build http clients with the `HttpClient` module.
|
|
|
370
370
|
Test handlers through an in-memory typed client with `HttpApiTest`, without
|
|
371
371
|
starting an HTTP server or touching a real database.
|
|
372
372
|
|
|
373
|
+
## Typed GraphQL clients with `effect/graphql`
|
|
374
|
+
|
|
375
|
+
Use the `effect/graphql` modules to call GraphQL APIs with typed
|
|
376
|
+
operations. Run `graphqlgen` from `@effect/graphql-generator` to generate
|
|
377
|
+
operations from `.graphql` documents, then build a client with
|
|
378
|
+
`GraphQLClient.make` and provide a transport from `GraphQLProtocol`.
|
|
379
|
+
|
|
380
|
+
- **[Getting started with GraphQL clients](./ai-docs/src/52_graphql/10_basics.ts)**:
|
|
381
|
+
Define a query, build a typed client service over HTTP with an auth
|
|
382
|
+
middleware, and page through a cursor connection.
|
|
383
|
+
- **[Testing code that uses a GraphQL client](./ai-docs/src/52_graphql/20_testing.ts)**:
|
|
384
|
+
Replace a client service with `GraphQLClient.layerTest`, answering each
|
|
385
|
+
operation from a handler instead of a server.
|
|
386
|
+
- **[Batching lookups with GraphQLResolver](./ai-docs/src/52_graphql/30_batching.ts)**:
|
|
387
|
+
Collect concurrent lookups by id into one `nodes(ids:)` request with
|
|
388
|
+
`GraphQLResolver`, instead of one request per id.
|
|
389
|
+
- **[Batching single-item lookups with aliases](./ai-docs/src/52_graphql/40_aliased-batching.ts)**:
|
|
390
|
+
When an API has no field that takes a list of keys, `GraphQLResolver.aliased`
|
|
391
|
+
repeats a generated operation under aliases, so concurrent lookups share one
|
|
392
|
+
request and keep their generated types.
|
|
393
|
+
- **[Handling GraphQL errors, retries and rate limits](./ai-docs/src/52_graphql/50_errors-and-retries.ts)**:
|
|
394
|
+
Read rate-limit headers in a middleware, tell GraphQL errors apart by their
|
|
395
|
+
fields, and retry timeouts and rate limits with `Effect.retry`.
|
|
396
|
+
|
|
373
397
|
## Working with child processes
|
|
374
398
|
|
|
375
399
|
Use the `effect/process` modules to define child processes and run them with `ChildProcessSpawner`.
|
package/ai-docs/package.json
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"@effect/sql-sqlite-wasm": "workspace:*",
|
|
28
28
|
"@effect/vitest": "workspace:*",
|
|
29
29
|
"effect": "workspace:*",
|
|
30
|
-
"hono": "^4.13.
|
|
31
|
-
"nodemailer": "^10.0
|
|
30
|
+
"hono": "^4.13.13",
|
|
31
|
+
"nodemailer": "^10.1.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/nodemailer": "^8.0.2"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Getting started with GraphQL clients
|
|
3
|
+
*
|
|
4
|
+
* Define a query, build a typed client service over HTTP with an auth
|
|
5
|
+
* middleware, and page through a cursor connection.
|
|
6
|
+
*/
|
|
7
|
+
import { Config, Context, Effect, Layer, Redacted, Schema, Stream } from "effect"
|
|
8
|
+
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLMiddleware, GraphQLProtocol } from "effect/graphql"
|
|
9
|
+
import { FetchHttpClient } from "effect/http"
|
|
10
|
+
|
|
11
|
+
// Operations are usually generated: write them in `.graphql` files and run
|
|
12
|
+
// `graphqlgen` from `@effect/graphql-generator`, which writes a `.graphql.ts`
|
|
13
|
+
// module like the one below next to each document.
|
|
14
|
+
//
|
|
15
|
+
// query RepoIssues($owner: String!, $name: String!, $after: String) {
|
|
16
|
+
// repository(owner: $owner, name: $name) {
|
|
17
|
+
// issues(first: 50, after: $after) {
|
|
18
|
+
// pageInfo { hasNextPage endCursor }
|
|
19
|
+
// nodes { number title }
|
|
20
|
+
// }
|
|
21
|
+
// }
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// Each nested selection is its own class, so editor hovers show its name.
|
|
25
|
+
export class PageInfo extends Schema.Opaque<PageInfo>()(
|
|
26
|
+
Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) })
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
export class Issue extends Schema.Opaque<Issue>()(
|
|
30
|
+
Schema.Struct({ number: Schema.Int, title: Schema.String })
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
export class IssueConnection extends Schema.Opaque<IssueConnection>()(
|
|
34
|
+
Schema.Struct({ pageInfo: PageInfo, nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Issue))) })
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
export class Repository extends Schema.Opaque<Repository>()(
|
|
38
|
+
Schema.Struct({ issues: IssueConnection })
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
export const RepoIssues = GraphQL.query("RepoIssues", {
|
|
42
|
+
document:
|
|
43
|
+
"query RepoIssues($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:50,after:$after){pageInfo{hasNextPage endCursor}nodes{number title}}}}",
|
|
44
|
+
variables: {
|
|
45
|
+
owner: Schema.String,
|
|
46
|
+
name: Schema.String,
|
|
47
|
+
after: Schema.optional(Schema.NullOr(Schema.String))
|
|
48
|
+
},
|
|
49
|
+
result: Schema.Struct({
|
|
50
|
+
repository: Schema.NullOr(Repository)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// A middleware is a service that wraps every operation it is attached to.
|
|
55
|
+
// `mapRequest` builds one from a single request transform.
|
|
56
|
+
export class GitHubAuth extends GraphQLMiddleware.Service<GitHubAuth>()("app/GitHubAuth") {
|
|
57
|
+
static readonly layer = Layer.effect(
|
|
58
|
+
GitHubAuth,
|
|
59
|
+
Effect.gen(function*() {
|
|
60
|
+
const token = yield* Config.Redacted("GITHUB_TOKEN")
|
|
61
|
+
return GraphQLMiddleware.mapRequest((request) =>
|
|
62
|
+
Effect.succeed({
|
|
63
|
+
...request,
|
|
64
|
+
headers: { ...request.headers, authorization: `Bearer ${Redacted.value(token)}` }
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A group is the set of operations a client exposes; the generator emits one
|
|
72
|
+
// per file. Attaching the middleware makes `GraphQLClient.make` require it.
|
|
73
|
+
export const GitHubGraphQLGroup = GraphQLGroup.make(RepoIssues).middleware(GitHubAuth)
|
|
74
|
+
|
|
75
|
+
// The client has one method per operation, named after it.
|
|
76
|
+
export class GitHub extends Context.Service<
|
|
77
|
+
GitHub,
|
|
78
|
+
GraphQLClient.FromGroup<typeof GitHubGraphQLGroup>
|
|
79
|
+
>()("app/GitHub") {
|
|
80
|
+
static readonly layerTest = GraphQLClient.layerTest(GitHub, GitHubGraphQLGroup)
|
|
81
|
+
|
|
82
|
+
static readonly layer = Layer.effect(GitHub, GraphQLClient.make(GitHubGraphQLGroup)).pipe(
|
|
83
|
+
Layer.provide(GitHubAuth.layer),
|
|
84
|
+
// Queries and mutations are sent as `POST`. Subscriptions use graphql-sse
|
|
85
|
+
// here, or graphql-ws with `GraphQLProtocol.layerWebSocket`.
|
|
86
|
+
Layer.provide(GraphQLProtocol.layerHttp({ url: "https://api.github.com/graphql" })),
|
|
87
|
+
Layer.provide(FetchHttpClient.layer)
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const program = Effect.gen(function*() {
|
|
92
|
+
const github = yield* GitHub
|
|
93
|
+
|
|
94
|
+
// Variables are encoded and the result decoded through the Schemas. Any
|
|
95
|
+
// `errors` in the response fail with a `GraphQLClientError`; pass
|
|
96
|
+
// `{ partial: true }` as the second argument to get them with the data.
|
|
97
|
+
const { repository } = yield* github.RepoIssues({ owner: "Effect-TS", name: "effect" })
|
|
98
|
+
yield* Effect.log(`First page has ${repository?.issues.nodes?.length ?? 0} issues`)
|
|
99
|
+
|
|
100
|
+
// `items` pages through the connection by driving `$after`, one request at
|
|
101
|
+
// a time and only as the stream is pulled.
|
|
102
|
+
const titles = yield* GraphQLClient.items(github.RepoIssues, {
|
|
103
|
+
variables: { owner: "Effect-TS", name: "effect" },
|
|
104
|
+
connection: (result) => result.repository?.issues
|
|
105
|
+
}).pipe(
|
|
106
|
+
Stream.take(120),
|
|
107
|
+
Stream.map((issue) => issue?.title),
|
|
108
|
+
Stream.runCollect
|
|
109
|
+
)
|
|
110
|
+
yield* Effect.log(`Fetched ${titles.length} issues`)
|
|
111
|
+
}).pipe(Effect.provide(GitHub.layer))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Testing code that uses a GraphQL client
|
|
3
|
+
*
|
|
4
|
+
* Replace a client service with `GraphQLClient.layerTest`, answering each
|
|
5
|
+
* operation from a handler instead of a server.
|
|
6
|
+
*/
|
|
7
|
+
import { assert, describe, it } from "@effect/vitest"
|
|
8
|
+
import { Effect } from "effect"
|
|
9
|
+
import { GitHub } from "./10_basics.ts"
|
|
10
|
+
|
|
11
|
+
// Code under test that depends on the `GitHub` client service.
|
|
12
|
+
const issueTitles = (owner: string, name: string) =>
|
|
13
|
+
GitHub.use((github) => github.RepoIssues({ owner, name })).pipe(
|
|
14
|
+
Effect.map(({ repository }) => repository?.issues.nodes?.map((issue) => issue?.title) ?? [])
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// The service binds `GraphQLClient.layerTest(GitHub, GitHubGraphQLGroup)` once
|
|
18
|
+
// as a static helper. Handlers are keyed by operation name, receive the
|
|
19
|
+
// caller's variables and options, and return results in their decoded form.
|
|
20
|
+
// Nothing is encoded or decoded and no middleware runs. An operation without
|
|
21
|
+
// a handler dies with an `UnimplementedError` defect.
|
|
22
|
+
const GitHubTest = GitHub.layerTest({
|
|
23
|
+
RepoIssues: ({ owner, name }) =>
|
|
24
|
+
Effect.succeed({
|
|
25
|
+
repository: {
|
|
26
|
+
issues: {
|
|
27
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
28
|
+
nodes: [{ number: 1, title: `Hello from ${owner}/${name}` }]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("issueTitles", () => {
|
|
35
|
+
it.effect("returns the titles of the first page", () =>
|
|
36
|
+
Effect.gen(function*() {
|
|
37
|
+
const titles = yield* issueTitles("Effect-TS", "effect")
|
|
38
|
+
assert.deepStrictEqual(titles, ["Hello from Effect-TS/effect"])
|
|
39
|
+
}).pipe(Effect.provide(GitHubTest)))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// `layerTest` uses `makeTest`, which skips the protocol and cannot return
|
|
43
|
+
// partial data together with `errors`. To test that, or the encode and decode
|
|
44
|
+
// path, build the real client with `GraphQLClient.make` over a scripted
|
|
45
|
+
// `GraphQLProtocol` layer (see `GraphQLProtocol`).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Batching lookups with GraphQLResolver
|
|
3
|
+
*
|
|
4
|
+
* Collect concurrent lookups by id into one `nodes(ids:)` request with
|
|
5
|
+
* `GraphQLResolver`, instead of one request per id.
|
|
6
|
+
*/
|
|
7
|
+
import { type Cause, Context, Effect, Layer, RequestResolver, Schema } from "effect"
|
|
8
|
+
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
|
|
9
|
+
import { FetchHttpClient } from "effect/http"
|
|
10
|
+
import { GitHubAuth } from "./10_basics.ts"
|
|
11
|
+
|
|
12
|
+
// The generator's output for `repo-nodes.graphql`:
|
|
13
|
+
//
|
|
14
|
+
// query RepoNodes($ids: [ID!]!) {
|
|
15
|
+
// nodes(ids: $ids) { __typename ... on Repository { nameWithOwner } }
|
|
16
|
+
// }
|
|
17
|
+
//
|
|
18
|
+
// Generated modules import shared codecs and the possible `__typename`s of
|
|
19
|
+
// each interface from the module the generator writes next to the schema
|
|
20
|
+
// (`import * as Shared from "../schema.graphql.ts"`); here `Shared` is
|
|
21
|
+
// declared inline.
|
|
22
|
+
const Shared = { ID: Schema.String }
|
|
23
|
+
declare namespace Shared {
|
|
24
|
+
export namespace Typename {
|
|
25
|
+
export type Node = "Issue" | "PullRequest" | "Repository" | "User"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Repository extends Schema.Opaque<Repository>()(Schema.Struct({
|
|
30
|
+
__typename: Schema.Literal("Repository"),
|
|
31
|
+
nameWithOwner: Schema.String
|
|
32
|
+
})) {}
|
|
33
|
+
|
|
34
|
+
// Unselected node types are represented by `otherTypename`.
|
|
35
|
+
export class NodeOther extends Schema.Opaque<NodeOther>()(Schema.Struct({
|
|
36
|
+
__typename: GraphQL.otherTypename<Shared.Typename.Node>()(["Repository"])
|
|
37
|
+
})) {}
|
|
38
|
+
|
|
39
|
+
/** `query RepoNodes` */
|
|
40
|
+
export const RepoNodes = GraphQL.query("RepoNodes", {
|
|
41
|
+
document: {
|
|
42
|
+
variables: [
|
|
43
|
+
{ name: "ids", body: "[ID!]!" }
|
|
44
|
+
],
|
|
45
|
+
selections: [
|
|
46
|
+
{ name: "nodes", body: ["(ids:$", "ids", "){__typename...on Repository{nameWithOwner}}"] }
|
|
47
|
+
],
|
|
48
|
+
fragments: []
|
|
49
|
+
},
|
|
50
|
+
variables: {
|
|
51
|
+
ids: Schema.Array(Shared.ID)
|
|
52
|
+
},
|
|
53
|
+
result: Schema.Struct({
|
|
54
|
+
nodes: Schema.Array(Schema.NullOr(Schema.Union([
|
|
55
|
+
Repository,
|
|
56
|
+
NodeOther
|
|
57
|
+
])))
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
export declare namespace RepoNodes {
|
|
61
|
+
export type Variables = typeof RepoNodes.variables.Type
|
|
62
|
+
export type Result = typeof RepoNodes.result.Type
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every operation in repo-nodes.graphql. */
|
|
66
|
+
export const RepoNodesGraphQLGroup = GraphQLGroup.make(RepoNodes)
|
|
67
|
+
|
|
68
|
+
// Your code: attach the middleware to the generated group.
|
|
69
|
+
export const GitHubGraphQLGroup = RepoNodesGraphQLGroup.middleware(GitHubAuth)
|
|
70
|
+
|
|
71
|
+
export class GitHub extends Context.Service<
|
|
72
|
+
GitHub,
|
|
73
|
+
GraphQLClient.FromGroup<typeof GitHubGraphQLGroup> & {
|
|
74
|
+
getRepo(id: string): Effect.Effect<
|
|
75
|
+
Repository,
|
|
76
|
+
GraphQL.Error<typeof RepoNodes> | Cause.NoSuchElementError
|
|
77
|
+
>
|
|
78
|
+
}
|
|
79
|
+
>()("app/GitHub") {
|
|
80
|
+
static readonly layer = Layer.effect(
|
|
81
|
+
GitHub,
|
|
82
|
+
Effect.gen(function*() {
|
|
83
|
+
const client = yield* GraphQLClient.make(GitHubGraphQLGroup)
|
|
84
|
+
|
|
85
|
+
// Null entries and non-Repository nodes fail with NoSuchElementError.
|
|
86
|
+
// GitHub accepts at most 100 ids per request.
|
|
87
|
+
//
|
|
88
|
+
// GitHubAuth reads one token from config, so every lookup can share a
|
|
89
|
+
// batch. If a middleware `requires` a per-request service, such as the
|
|
90
|
+
// signed-in user, `batchKey` is required and keeps lookups for different
|
|
91
|
+
// users in separate calls:
|
|
92
|
+
//
|
|
93
|
+
// batchKey: (context) => Context.get(context, CurrentUser).id
|
|
94
|
+
const RepoById = GraphQLResolver.ordered(client.RepoNodes, {
|
|
95
|
+
variables: (ids: ReadonlyArray<string>) => ({ ids }),
|
|
96
|
+
path: ["nodes"],
|
|
97
|
+
typename: "Repository"
|
|
98
|
+
}).pipe(RequestResolver.batchN(100))
|
|
99
|
+
return { ...client, getRepo: GraphQLResolver.request(RepoById) }
|
|
100
|
+
})
|
|
101
|
+
).pipe(
|
|
102
|
+
Layer.provide(GitHubAuth.layer),
|
|
103
|
+
Layer.provide(GraphQLProtocol.layerHttp({ url: "https://api.github.com/graphql" })),
|
|
104
|
+
Layer.provide(FetchHttpClient.layer)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const repoNames = Effect.fn("repoNames")(function*(ids: ReadonlyArray<string>) {
|
|
109
|
+
const github = yield* GitHub
|
|
110
|
+
const repos = yield* Effect.forEach(ids, github.getRepo, { concurrency: "unbounded" })
|
|
111
|
+
return repos.map((repo) => repo.nameWithOwner)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
export const program = repoNames(["R_1", "R_2"]).pipe(
|
|
115
|
+
Effect.provide(GitHub.layer)
|
|
116
|
+
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Batching single-item lookups with aliases
|
|
3
|
+
*
|
|
4
|
+
* When an API has no field that takes a list of keys, `GraphQLResolver.aliased`
|
|
5
|
+
* repeats a generated operation under aliases, so concurrent lookups share one
|
|
6
|
+
* request and keep their generated types.
|
|
7
|
+
*/
|
|
8
|
+
import { Context, Effect, Layer, Schema } from "effect"
|
|
9
|
+
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
|
|
10
|
+
import { FetchHttpClient } from "effect/http"
|
|
11
|
+
import { GitHubAuth } from "./10_basics.ts"
|
|
12
|
+
|
|
13
|
+
// The generator's output for `pr.graphql`:
|
|
14
|
+
//
|
|
15
|
+
// query PullRequest($owner: String!, $name: String!, $number: Int!) {
|
|
16
|
+
// repository(owner: $owner, name: $name) {
|
|
17
|
+
// pullRequest(number: $number) { number title }
|
|
18
|
+
// }
|
|
19
|
+
// }
|
|
20
|
+
//
|
|
21
|
+
// Generated modules import shared codecs, such as `Int`, from the module the
|
|
22
|
+
// generator writes next to the schema (`import * as Shared from
|
|
23
|
+
// "../schema.graphql.ts"`); here `Shared` is declared inline.
|
|
24
|
+
const Shared = { Int: Schema.Number.check(Schema.isInt32()) }
|
|
25
|
+
|
|
26
|
+
export class PullRequestPullRequest extends Schema.Opaque<PullRequestPullRequest>()(Schema.Struct({
|
|
27
|
+
number: Shared.Int,
|
|
28
|
+
title: Schema.String
|
|
29
|
+
})) {}
|
|
30
|
+
|
|
31
|
+
export class Repository extends Schema.Opaque<Repository>()(Schema.Struct({
|
|
32
|
+
pullRequest: Schema.NullOr(PullRequestPullRequest)
|
|
33
|
+
})) {}
|
|
34
|
+
|
|
35
|
+
// The generator gives queries and mutations their document as a
|
|
36
|
+
// `GraphQL.DocumentTemplate`. The odd entries of each `body` are the names
|
|
37
|
+
// `aliased` renames per slice; joined together they are the printed document.
|
|
38
|
+
/** `query PullRequest` */
|
|
39
|
+
export const PullRequest = GraphQL.query("PullRequest", {
|
|
40
|
+
document: {
|
|
41
|
+
variables: [
|
|
42
|
+
{ name: "owner", body: "String!" },
|
|
43
|
+
{ name: "name", body: "String!" },
|
|
44
|
+
{ name: "number", body: "Int!" }
|
|
45
|
+
],
|
|
46
|
+
selections: [
|
|
47
|
+
{
|
|
48
|
+
name: "repository",
|
|
49
|
+
body: ["(owner:$", "owner", " name:$", "name", "){pullRequest(number:$", "number", "){number title}}"]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
fragments: []
|
|
53
|
+
},
|
|
54
|
+
variables: {
|
|
55
|
+
owner: Schema.String,
|
|
56
|
+
name: Schema.String,
|
|
57
|
+
number: Shared.Int
|
|
58
|
+
},
|
|
59
|
+
result: Schema.Struct({
|
|
60
|
+
repository: Schema.NullOr(Repository)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
export declare namespace PullRequest {
|
|
64
|
+
export type Variables = typeof PullRequest.variables.Type
|
|
65
|
+
export type Result = typeof PullRequest.result.Type
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every operation in pr.graphql. */
|
|
69
|
+
export const PrGraphQLGroup = GraphQLGroup.make(PullRequest)
|
|
70
|
+
|
|
71
|
+
// Your code: attach the middleware to the generated group.
|
|
72
|
+
export const GitHubGraphQLGroup = PrGraphQLGroup.middleware(GitHubAuth)
|
|
73
|
+
|
|
74
|
+
export class GitHub extends Context.Service<
|
|
75
|
+
GitHub,
|
|
76
|
+
GraphQLClient.FromGroup<typeof GitHubGraphQLGroup> & {
|
|
77
|
+
readonly getPullRequest: (
|
|
78
|
+
variables: GraphQL.Variables<typeof PullRequest>
|
|
79
|
+
) => Effect.Effect<GraphQL.Result<typeof PullRequest>, GraphQL.Error<typeof PullRequest>>
|
|
80
|
+
}
|
|
81
|
+
>()("app/GitHub") {
|
|
82
|
+
static readonly layer = Layer.effect(
|
|
83
|
+
GitHub,
|
|
84
|
+
Effect.gen(function*() {
|
|
85
|
+
const client = yield* GraphQLClient.make(GitHubGraphQLGroup)
|
|
86
|
+
|
|
87
|
+
// Concurrent calls go out as one POST whose root fields are aliased
|
|
88
|
+
// `_0_repository`, `_1_repository`, and so on. Each slice is decoded
|
|
89
|
+
// with the generated result Schema, and a GraphQL error under
|
|
90
|
+
// `_1_repository` fails only that call.
|
|
91
|
+
//
|
|
92
|
+
// A batch holds 10 slices by default, because GitHub's query cost and
|
|
93
|
+
// timeouts grow with the size of the query. Pipe
|
|
94
|
+
// `RequestResolver.batchN` to change it.
|
|
95
|
+
const PullRequests = GraphQLResolver.aliased(client.PullRequest)
|
|
96
|
+
|
|
97
|
+
// `request` turns the resolver into a lookup by the operation's
|
|
98
|
+
// variables, as for `ordered` and `findById`.
|
|
99
|
+
return { ...client, getPullRequest: GraphQLResolver.request(PullRequests) }
|
|
100
|
+
})
|
|
101
|
+
).pipe(
|
|
102
|
+
Layer.provide(GitHubAuth.layer),
|
|
103
|
+
Layer.provide(GraphQLProtocol.layerHttp({ url: "https://api.github.com/graphql" })),
|
|
104
|
+
Layer.provide(FetchHttpClient.layer)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const pullRequestTitles = Effect.fn("pullRequestTitles")(function*(numbers: ReadonlyArray<number>) {
|
|
109
|
+
const github = yield* GitHub
|
|
110
|
+
const results = yield* Effect.forEach(
|
|
111
|
+
numbers,
|
|
112
|
+
(number) => github.getPullRequest({ owner: "Effect-TS", name: "effect", number }),
|
|
113
|
+
{ concurrency: "unbounded" }
|
|
114
|
+
)
|
|
115
|
+
// `repository` and `pullRequest` are nullable, so a missing pull request is
|
|
116
|
+
// `null` rather than an error that would fail every slice.
|
|
117
|
+
return results.map((result) => result.repository?.pullRequest?.title ?? null)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
export const program = pullRequestTitles([1, 2, 3]).pipe(
|
|
121
|
+
Effect.provide(GitHub.layer)
|
|
122
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Handling GraphQL errors, retries and rate limits
|
|
3
|
+
*
|
|
4
|
+
* Read rate-limit headers in a middleware, tell GraphQL errors apart by their
|
|
5
|
+
* fields, and retry timeouts and rate limits with `Effect.retry`.
|
|
6
|
+
*/
|
|
7
|
+
import { Duration, Effect, Layer, Schedule } from "effect"
|
|
8
|
+
import { type GraphQLClientError, GraphQLMiddleware } from "effect/graphql"
|
|
9
|
+
import { GitHub } from "./10_basics.ts"
|
|
10
|
+
|
|
11
|
+
// Middleware see each response's HTTP `status` and `headers` on the result
|
|
12
|
+
// `next` returns, including responses with GraphQL `errors`. A failed call
|
|
13
|
+
// keeps them, and `GraphQLClientError` exposes them as `headers` and
|
|
14
|
+
// `status`. Attach it with `GraphQLGroup.make(...).middleware(RateLimit)`.
|
|
15
|
+
export class RateLimit extends GraphQLMiddleware.Service<RateLimit>()("app/RateLimit") {
|
|
16
|
+
static readonly layer = Layer.succeed(RateLimit, {
|
|
17
|
+
execute: ({ next, request }) =>
|
|
18
|
+
Effect.tap(next(request), (result) => {
|
|
19
|
+
const remaining = Number(result.headers?.["x-ratelimit-remaining"])
|
|
20
|
+
return remaining < 500
|
|
21
|
+
? Effect.logWarning(`GitHub rate limit: ${remaining} points left`)
|
|
22
|
+
: Effect.void
|
|
23
|
+
}),
|
|
24
|
+
subscribe: ({ next, request }) => next(request)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// GraphQL errors keep fields the specification doesn't define as JSON, such
|
|
29
|
+
// as GitHub's top-level `type` (`NOT_FOUND`, `FORBIDDEN`, `RATE_LIMITED`).
|
|
30
|
+
export const hasErrorType = (error: GraphQLClientError.GraphQLClientError, type: string) =>
|
|
31
|
+
error.reason._tag === "ResponseError" && error.reason.errors.every((entry) => entry.type === type)
|
|
32
|
+
|
|
33
|
+
// `isRetryable` is true for network failures, for 429 or 5xx responses, and
|
|
34
|
+
// for any error with a `Retry-After` header, whatever its status. That covers
|
|
35
|
+
// GitHub's 502 and 504 query timeouts, even with a GraphQL `errors` body, and
|
|
36
|
+
// its secondary rate limits (403 or 429 with `retry-after`). A primary rate
|
|
37
|
+
// limit can come without `retry-after`, with `x-ratelimit-remaining: 0`, so
|
|
38
|
+
// check that header too.
|
|
39
|
+
const rateLimited = (error: GraphQLClientError.GraphQLClientError) => error.headers?.["x-ratelimit-remaining"] === "0"
|
|
40
|
+
|
|
41
|
+
// Wait for `retryAfter` when it is set, and until `x-ratelimit-reset` (epoch
|
|
42
|
+
// seconds) on a primary rate limit. GitHub may ban an integration that keeps
|
|
43
|
+
// retrying while rate limited, so don't retry with a plain backoff.
|
|
44
|
+
const gitHubRetry = Schedule.exponential("1 second").pipe(
|
|
45
|
+
Schedule.setInputType<GraphQLClientError.GraphQLClientError>(),
|
|
46
|
+
Schedule.modifyDelay(({ duration, input, now }) => {
|
|
47
|
+
if (input.retryAfter !== undefined) return Effect.succeed(input.retryAfter)
|
|
48
|
+
if (!rateLimited(input)) return Effect.succeed(duration)
|
|
49
|
+
const reset = Number(input.headers?.["x-ratelimit-reset"]) * 1000
|
|
50
|
+
return Effect.succeed(Number.isFinite(reset) ? Duration.millis(Math.max(0, reset - now)) : Duration.minutes(1))
|
|
51
|
+
})
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// The client never retries queries or mutations on its own. Retry only
|
|
55
|
+
// mutations that are safe to send twice, and with `GraphQLClient.pages`,
|
|
56
|
+
// retry inside a middleware, because `Stream.retry` starts again from the
|
|
57
|
+
// first page. Subscriptions reconnect on their own after a retryable failure,
|
|
58
|
+
// on `GraphQLClient.defaultSubscriptionRetry`, which waits for `retryAfter`.
|
|
59
|
+
// A primary rate limit without `retry-after` isn't retryable, so it ends the
|
|
60
|
+
// subscription; wait until `x-ratelimit-reset` before subscribing again.
|
|
61
|
+
export const retryGitHub = <A, R>(effect: Effect.Effect<A, GraphQLClientError.GraphQLClientError, R>) =>
|
|
62
|
+
Effect.retry(effect, {
|
|
63
|
+
schedule: gitHubRetry,
|
|
64
|
+
while: (error) => error.isRetryable || rateLimited(error),
|
|
65
|
+
times: 5
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export const program = Effect.gen(function*() {
|
|
69
|
+
const github = yield* GitHub
|
|
70
|
+
const result = yield* retryGitHub(github.RepoIssues({ owner: "Effect-TS", name: "missing" })).pipe(
|
|
71
|
+
Effect.catchIf((error) => hasErrorType(error, "NOT_FOUND"), () => Effect.succeed(null))
|
|
72
|
+
)
|
|
73
|
+
yield* Effect.log(result === null ? "No such repository" : `${result.repository?.issues.nodes?.length} issues`)
|
|
74
|
+
}).pipe(Effect.provide(GitHub.layer))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
## Typed GraphQL clients with `effect/graphql`
|
|
2
|
+
|
|
3
|
+
Use the `effect/graphql` modules to call GraphQL APIs with typed
|
|
4
|
+
operations. Run `graphqlgen` from `@effect/graphql-generator` to generate
|
|
5
|
+
operations from `.graphql` documents, then build a client with
|
|
6
|
+
`GraphQLClient.make` and provide a transport from `GraphQLProtocol`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect/platform-bun",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "4.0.
|
|
4
|
+
"version": "4.0.4",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Platform specific implementations for the Bun runtime",
|
|
7
7
|
"homepage": "https://effect.website",
|
|
@@ -49,15 +49,15 @@
|
|
|
49
49
|
"provenance": true
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"effect": "^4.0.
|
|
52
|
+
"effect": "^4.0.4"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@effect/platform-node-shared": "^4.0.
|
|
55
|
+
"@effect/platform-node-shared": "^4.0.4"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/bun": "^1.4.2",
|
|
59
|
-
"tinybench": "^6.2.
|
|
60
|
-
"effect": "^4.0.
|
|
59
|
+
"tinybench": "^6.2.1",
|
|
60
|
+
"effect": "^4.0.4"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"codegen": "effect-utils codegen",
|