@model-ts/graphql 0.0.0-stage → 7.0.0
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 +21 -0
- package/README.md +1280 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/Assembly.d.ts +24 -0
- package/dist/Assembly.d.ts.map +1 -0
- package/dist/Assembly.js +236 -0
- package/dist/Assembly.js.map +1 -0
- package/dist/Builder.d.ts +27 -0
- package/dist/Builder.d.ts.map +1 -0
- package/dist/Builder.js +40 -0
- package/dist/Builder.js.map +1 -0
- package/dist/Connections.d.ts +41 -0
- package/dist/Connections.d.ts.map +1 -0
- package/dist/Connections.js +243 -0
- package/dist/Connections.js.map +1 -0
- package/dist/Definitions.d.ts +139 -0
- package/dist/Definitions.d.ts.map +1 -0
- package/dist/Definitions.js +117 -0
- package/dist/Definitions.js.map +1 -0
- package/dist/Errors.d.ts +39 -0
- package/dist/Errors.d.ts.map +1 -0
- package/dist/Errors.js +103 -0
- package/dist/Errors.js.map +1 -0
- package/dist/Execution.d.ts +52 -0
- package/dist/Execution.d.ts.map +1 -0
- package/dist/Execution.js +174 -0
- package/dist/Execution.js.map +1 -0
- package/dist/FieldRuntime.d.ts +5 -0
- package/dist/FieldRuntime.d.ts.map +1 -0
- package/dist/FieldRuntime.js +27 -0
- package/dist/FieldRuntime.js.map +1 -0
- package/dist/FieldTypes.d.ts +232 -0
- package/dist/FieldTypes.d.ts.map +1 -0
- package/dist/FieldTypes.js +6 -0
- package/dist/FieldTypes.js.map +1 -0
- package/dist/Fields.d.ts +8 -0
- package/dist/Fields.d.ts.map +1 -0
- package/dist/Fields.js +72 -0
- package/dist/Fields.js.map +1 -0
- package/dist/Formatting.d.ts +25 -0
- package/dist/Formatting.d.ts.map +1 -0
- package/dist/Formatting.js +29 -0
- package/dist/Formatting.js.map +1 -0
- package/dist/GraphQL.d.ts +10 -0
- package/dist/GraphQL.d.ts.map +1 -0
- package/dist/GraphQL.js +5 -0
- package/dist/GraphQL.js.map +1 -0
- package/dist/InputTypes.d.ts +27 -0
- package/dist/InputTypes.d.ts.map +1 -0
- package/dist/InputTypes.js +203 -0
- package/dist/InputTypes.js.map +1 -0
- package/dist/Registry.d.ts +70 -0
- package/dist/Registry.d.ts.map +1 -0
- package/dist/Registry.js +3 -0
- package/dist/Registry.js.map +1 -0
- package/dist/Resolver.d.ts +30 -0
- package/dist/Resolver.d.ts.map +1 -0
- package/dist/Resolver.js +76 -0
- package/dist/Resolver.js.map +1 -0
- package/dist/Results.d.ts +20 -0
- package/dist/Results.d.ts.map +1 -0
- package/dist/Results.js +79 -0
- package/dist/Results.js.map +1 -0
- package/dist/ScalarBoundary.d.ts +9 -0
- package/dist/ScalarBoundary.d.ts.map +1 -0
- package/dist/ScalarBoundary.js +31 -0
- package/dist/ScalarBoundary.js.map +1 -0
- package/dist/Scalars.d.ts +9 -0
- package/dist/Scalars.d.ts.map +1 -0
- package/dist/Scalars.js +104 -0
- package/dist/Scalars.js.map +1 -0
- package/dist/SchemaTypes.d.ts +55 -0
- package/dist/SchemaTypes.d.ts.map +1 -0
- package/dist/SchemaTypes.js +47 -0
- package/dist/SchemaTypes.js.map +1 -0
- package/dist/Subscription.d.ts +6 -0
- package/dist/Subscription.d.ts.map +1 -0
- package/dist/Subscription.js +60 -0
- package/dist/Subscription.js.map +1 -0
- package/dist/Testing.d.ts +52 -0
- package/dist/Testing.d.ts.map +1 -0
- package/dist/Testing.js +74 -0
- package/dist/Testing.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/SubscriptionRule.d.ts +8 -0
- package/dist/internal/SubscriptionRule.d.ts.map +1 -0
- package/dist/internal/SubscriptionRule.js +73 -0
- package/dist/internal/SubscriptionRule.js.map +1 -0
- package/package.json +55 -4
- package/src/Assembly.ts +352 -0
- package/src/Builder.ts +110 -0
- package/src/Connections.ts +374 -0
- package/src/Definitions.ts +465 -0
- package/src/Errors.ts +188 -0
- package/src/Execution.ts +354 -0
- package/src/FieldRuntime.ts +32 -0
- package/src/FieldTypes.ts +667 -0
- package/src/Fields.ts +117 -0
- package/src/Formatting.ts +54 -0
- package/src/GraphQL.ts +50 -0
- package/src/InputTypes.ts +266 -0
- package/src/Registry.ts +157 -0
- package/src/Resolver.ts +176 -0
- package/src/Results.ts +128 -0
- package/src/ScalarBoundary.ts +30 -0
- package/src/Scalars.ts +143 -0
- package/src/SchemaTypes.ts +90 -0
- package/src/Subscription.ts +93 -0
- package/src/Testing.ts +144 -0
- package/src/index.ts +1 -0
- package/src/internal/SubscriptionRule.ts +90 -0
package/README.md
CHANGED
|
@@ -1,3 +1,1281 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @model-ts/graphql
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build GraphQL APIs with model-ts models or ordinary Effect schemas. Define fields
|
|
4
|
+
as plain objects, return values or Effects from resolvers, and let TypeScript check the parent,
|
|
5
|
+
arguments, result, request context, and required services together.
|
|
6
|
+
|
|
7
|
+
The field API keeps related definitions together in a compact form. Model
|
|
8
|
+
registration replaces server-side schema type generation: there is no generated
|
|
9
|
+
resolver-types file to keep in sync. Models stay free of GraphQL metadata, while the API decides which
|
|
10
|
+
fields to expose. Typed failures can become result unions that clients handle
|
|
11
|
+
with fragments. Storage, logging, and other services come from Effect layers.
|
|
12
|
+
|
|
13
|
+
This package builds and executes schemas. It does not provide an HTTP or
|
|
14
|
+
WebSocket server.
|
|
15
|
+
|
|
16
|
+
## Contents
|
|
17
|
+
|
|
18
|
+
- [Install](#install)
|
|
19
|
+
- [Quick start](#quick-start)
|
|
20
|
+
- [Registration and type safety](#registration-and-type-safety)
|
|
21
|
+
- [Objects and fields](#objects-and-fields)
|
|
22
|
+
- [Arguments and inputs](#arguments-and-inputs)
|
|
23
|
+
- [Enums](#enums)
|
|
24
|
+
- [Interfaces and unions](#interfaces-and-unions)
|
|
25
|
+
- [Mutations and typed results](#mutations-and-typed-results)
|
|
26
|
+
- [Connections and pagination](#connections-and-pagination)
|
|
27
|
+
- [Scalars](#scalars)
|
|
28
|
+
- [Subscriptions](#subscriptions)
|
|
29
|
+
- [Request context and layers](#request-context-and-layers)
|
|
30
|
+
- [Error policy and reporting](#error-policy-and-reporting)
|
|
31
|
+
- [Testing](#testing)
|
|
32
|
+
- [Calling Promise APIs from resolvers](#calling-promise-apis-from-resolvers)
|
|
33
|
+
- [API summary](#api-summary)
|
|
34
|
+
- [Development](#development)
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Use Node.js 22 or later and the coordinated model-ts 7 packages:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm install @model-ts/core@^7 @model-ts/graphql@^7 effect@^4.0.5 graphql@^16.11.0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The examples below also use DynamoDB models and their in-memory sandbox:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm install @model-ts/dynamodb@^7 @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/util-dynamodb
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`effect` and `graphql` are peer dependencies. The supported ranges are
|
|
51
|
+
`effect@^4.0.5` and `graphql@^16.11.0`. Use one installed copy of GraphQL
|
|
52
|
+
across your application and schema libraries.
|
|
53
|
+
|
|
54
|
+
The examples use TypeScript 7 and explicit `.ts` imports. Configure your project's
|
|
55
|
+
TypeScript build to rewrite those extensions, or adapt imports to your toolchain.
|
|
56
|
+
Client operation code generation is optional and independent of this builder.
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
This example exposes lists and their workspaces, loads them through the DynamoDB
|
|
61
|
+
provider, and updates a list with a typed result. It runs entirely in memory.
|
|
62
|
+
|
|
63
|
+
### 1. Define the models
|
|
64
|
+
|
|
65
|
+
Keep IDs, persistence fields, and keys in the store. Each ID has a distinct brand;
|
|
66
|
+
GraphQL will expose them as `ID` without losing those distinctions in resolvers.
|
|
67
|
+
|
|
68
|
+
`store/models.ts`
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { Model } from "@model-ts/core"
|
|
72
|
+
import { DynamoDB } from "@model-ts/dynamodb"
|
|
73
|
+
import { Schema } from "effect"
|
|
74
|
+
|
|
75
|
+
export const WorkspaceId = Schema.String.pipe(Schema.brand("WorkspaceId"))
|
|
76
|
+
export type WorkspaceId = typeof WorkspaceId.Type
|
|
77
|
+
export const ListId = Schema.String.pipe(Schema.brand("ListId"))
|
|
78
|
+
export type ListId = typeof ListId.Type
|
|
79
|
+
|
|
80
|
+
export class Workspace extends Model.Class<Workspace>()(
|
|
81
|
+
"Workspace",
|
|
82
|
+
DynamoDB.provider,
|
|
83
|
+
{ id: WorkspaceId, name: Schema.String },
|
|
84
|
+
) {
|
|
85
|
+
get PK() {
|
|
86
|
+
return `WORKSPACE#${this.id}`
|
|
87
|
+
}
|
|
88
|
+
get SK() {
|
|
89
|
+
return "WORKSPACE"
|
|
90
|
+
}
|
|
91
|
+
static key(id: WorkspaceId) {
|
|
92
|
+
return { PK: `WORKSPACE#${id}`, SK: "WORKSPACE" }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class List extends Model.Class<List>()("List", DynamoDB.provider, {
|
|
97
|
+
id: ListId,
|
|
98
|
+
workspaceId: WorkspaceId,
|
|
99
|
+
name: Schema.String,
|
|
100
|
+
description: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
101
|
+
}) {
|
|
102
|
+
get PK() {
|
|
103
|
+
return `LIST#${this.id}`
|
|
104
|
+
}
|
|
105
|
+
get SK() {
|
|
106
|
+
return "LIST"
|
|
107
|
+
}
|
|
108
|
+
get GSI1PK() {
|
|
109
|
+
return `WORKSPACE#${this.workspaceId}`
|
|
110
|
+
}
|
|
111
|
+
get GSI1SK() {
|
|
112
|
+
return this.PK
|
|
113
|
+
}
|
|
114
|
+
static key(id: ListId) {
|
|
115
|
+
return { PK: `LIST#${id}`, SK: "LIST" }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 2. Create the application builder
|
|
121
|
+
|
|
122
|
+
`Types` is the application's registry. Each definition file extends it locally;
|
|
123
|
+
there is no central list of model names to maintain.
|
|
124
|
+
|
|
125
|
+
`api/graphql.ts`
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import {
|
|
129
|
+
ConditionalCheckFailedError,
|
|
130
|
+
DynamoDBError,
|
|
131
|
+
ItemDecodeError,
|
|
132
|
+
ItemEncodeError,
|
|
133
|
+
ItemNotFoundError,
|
|
134
|
+
KeyExistsError,
|
|
135
|
+
PaginationError,
|
|
136
|
+
RaceConditionError,
|
|
137
|
+
TransactionCanceledError,
|
|
138
|
+
} from "@model-ts/dynamodb"
|
|
139
|
+
import { createBuilder } from "@model-ts/graphql"
|
|
140
|
+
|
|
141
|
+
export type { Register } from "@model-ts/graphql"
|
|
142
|
+
export interface Types {}
|
|
143
|
+
export interface AppContext {
|
|
144
|
+
readonly requestId: string
|
|
145
|
+
readonly identity: {
|
|
146
|
+
readonly userId: string
|
|
147
|
+
readonly canWrite: boolean
|
|
148
|
+
} | null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const GraphQL = createBuilder<Types, AppContext>().withErrorPolicy({
|
|
152
|
+
internal: [
|
|
153
|
+
ConditionalCheckFailedError,
|
|
154
|
+
DynamoDBError,
|
|
155
|
+
ItemDecodeError,
|
|
156
|
+
ItemEncodeError,
|
|
157
|
+
ItemNotFoundError,
|
|
158
|
+
KeyExistsError,
|
|
159
|
+
PaginationError,
|
|
160
|
+
RaceConditionError,
|
|
161
|
+
TransactionCanceledError,
|
|
162
|
+
],
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The policy lets resolvers use storage operations while keeping their failures
|
|
167
|
+
private. Translate a storage failure into a domain error when clients should
|
|
168
|
+
handle it, as the mutation below does.
|
|
169
|
+
|
|
170
|
+
The builder carries the definitions, the field helpers such as
|
|
171
|
+
`GraphQL.string()` and `GraphQL.field(...)`, and execution. It is your
|
|
172
|
+
application's own value, so you may export it under a shorter name such as `G`.
|
|
173
|
+
|
|
174
|
+
### 3. Expose objects
|
|
175
|
+
|
|
176
|
+
Register the **model**, then define its GraphQL fields. The object's name comes
|
|
177
|
+
from its model tag. No registration is needed for individual fields.
|
|
178
|
+
|
|
179
|
+
`api/types/workspace.ts`
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { Workspace } from "../../store/models.ts"
|
|
183
|
+
import { GraphQL, type Register } from "../graphql.ts"
|
|
184
|
+
|
|
185
|
+
declare module "../graphql.ts" {
|
|
186
|
+
interface Types extends Register<typeof Workspace> {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const WorkspaceType = GraphQL.object(Workspace, {
|
|
190
|
+
id: GraphQL.id(),
|
|
191
|
+
name: GraphQL.string(),
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`api/types/list.ts`
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { List, Workspace } from "../../store/models.ts"
|
|
199
|
+
import { GraphQL, type Register } from "../graphql.ts"
|
|
200
|
+
|
|
201
|
+
declare module "../graphql.ts" {
|
|
202
|
+
interface Types extends Register<typeof List> {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const ListType = GraphQL.object(
|
|
206
|
+
List,
|
|
207
|
+
{ description: "A list belonging to a workspace." },
|
|
208
|
+
{
|
|
209
|
+
id: GraphQL.id(),
|
|
210
|
+
name: GraphQL.string(),
|
|
211
|
+
description: GraphQL.nullable.string(),
|
|
212
|
+
workspace: GraphQL.field("Workspace", (list) =>
|
|
213
|
+
Workspace.load(Workspace.key(list.workspaceId)),
|
|
214
|
+
),
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Here `list` is a `List`, the resolver returns an Effect requiring `DynamoDB`, and
|
|
220
|
+
that requirement becomes part of the built schema's type.
|
|
221
|
+
|
|
222
|
+
### 4. Add a query
|
|
223
|
+
|
|
224
|
+
`api/query/list.ts`
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { Effect } from "effect"
|
|
228
|
+
import { List, ListId } from "../../store/models.ts"
|
|
229
|
+
import { GraphQL } from "../graphql.ts"
|
|
230
|
+
|
|
231
|
+
export const ListQuery = GraphQL.queryField("list", GraphQL.nullable("List"), {
|
|
232
|
+
args: { id: GraphQL.id(ListId) },
|
|
233
|
+
authorize: (_, __, context) => context.identity !== null,
|
|
234
|
+
resolve: (_, { id }) =>
|
|
235
|
+
List.load(List.key(id)).pipe(
|
|
236
|
+
Effect.catchTag("ItemNotFoundError", () => Effect.succeed(null)),
|
|
237
|
+
),
|
|
238
|
+
})
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Each root field is an exported constant with a name, an output type, and a config
|
|
242
|
+
or resolver. The output type is a registered or scalar name, or a config-less
|
|
243
|
+
`nullable`, `list`, or `connection` helper. It determines the resolver result and
|
|
244
|
+
available config options, just as on object fields. For example:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
export const ListsQuery = GraphQL.queryField(
|
|
248
|
+
"lists",
|
|
249
|
+
GraphQL.list("List"),
|
|
250
|
+
() => [],
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Roots have no parent property to read, so the third argument is required. Use
|
|
255
|
+
`resolve` (or resolver shorthand), `nodes` for a connection, or `subscribe` for
|
|
256
|
+
a subscription. Pass connection options such as `args`, `name`, `edgeName`,
|
|
257
|
+
`authorize`, and `totalCount` in that third argument.
|
|
258
|
+
|
|
259
|
+
A missing list becomes `null`. A denied request produces a GraphQL field error
|
|
260
|
+
with code `FORBIDDEN`. A storage outage follows the internal-error policy.
|
|
261
|
+
|
|
262
|
+
### 5. Add a mutation with an input class and typed errors
|
|
263
|
+
|
|
264
|
+
Keep a mutation's input, expected failures, and resolver together. These error
|
|
265
|
+
classes become GraphQL result members automatically; they do not need object
|
|
266
|
+
registration unless you want custom fields or interfaces on them.
|
|
267
|
+
|
|
268
|
+
`api/mutation/list-update.ts`
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
import { Effect, Schema } from "effect"
|
|
272
|
+
import { List, ListId } from "../../store/models.ts"
|
|
273
|
+
import { GraphQL } from "../graphql.ts"
|
|
274
|
+
|
|
275
|
+
export class NotFoundError extends Schema.TaggedError<NotFoundError>()(
|
|
276
|
+
"NotFoundError",
|
|
277
|
+
{ message: Schema.String },
|
|
278
|
+
) {}
|
|
279
|
+
export class UserInputError extends Schema.TaggedError<UserInputError>()(
|
|
280
|
+
"UserInputError",
|
|
281
|
+
{ message: Schema.String, field: Schema.String },
|
|
282
|
+
) {}
|
|
283
|
+
export class ListUpdateInput extends Schema.Class<ListUpdateInput>(
|
|
284
|
+
"ListUpdateInput",
|
|
285
|
+
)({
|
|
286
|
+
name: Schema.optionalKey(Schema.NonEmptyString),
|
|
287
|
+
description: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
288
|
+
}) {}
|
|
289
|
+
|
|
290
|
+
export const ListUpdateMutation = GraphQL.mutationField("listUpdate", "List", {
|
|
291
|
+
args: { id: GraphQL.id(ListId), input: ListUpdateInput },
|
|
292
|
+
errors: [NotFoundError, UserInputError],
|
|
293
|
+
authorize: (_, __, { identity }) => identity?.canWrite === true,
|
|
294
|
+
resolve: (_, { id, input }) =>
|
|
295
|
+
Effect.gen(function* () {
|
|
296
|
+
const list = yield* List.load(List.key(id)).pipe(
|
|
297
|
+
Effect.catchTag("ItemNotFoundError", () =>
|
|
298
|
+
Effect.fail(new NotFoundError({ message: "List not found" })),
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
return yield* list.update(input)
|
|
302
|
+
}),
|
|
303
|
+
})
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The generated field is `listUpdate: ListUpdateResult!`, with
|
|
307
|
+
`union ListUpdateResult = List | NotFoundError | UserInputError`. When a field
|
|
308
|
+
lists a `UserInputError` class with `message` and `field`, argument decoding can
|
|
309
|
+
return that result member for Schema validation failures. Errors rejected earlier
|
|
310
|
+
by GraphQL coercion remain ordinary GraphQL errors.
|
|
311
|
+
|
|
312
|
+
### 6. Assemble the schema
|
|
313
|
+
|
|
314
|
+
Use `export *` barrels. Assembly checks that every registered definition appears
|
|
315
|
+
in the supplied module; input and error class exports can share the barrel.
|
|
316
|
+
Root field exports are merged into `Query`, `Mutation`, and `Subscription`. Two
|
|
317
|
+
definitions of the same name on the same root fail assembly with a message such
|
|
318
|
+
as `Duplicate field: Query.list`. The same name on different roots is valid.
|
|
319
|
+
|
|
320
|
+
`api/definitions.ts`
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
export * from "./types/workspace.ts"
|
|
324
|
+
export * from "./types/list.ts"
|
|
325
|
+
export * from "./query/list.ts"
|
|
326
|
+
export * from "./mutation/list-update.ts"
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`api/schema.ts`
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import * as definitions from "./definitions.ts"
|
|
333
|
+
import { GraphQL } from "./graphql.ts"
|
|
334
|
+
|
|
335
|
+
export const schema = GraphQL.schema(definitions)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### 7. Execute with shared services and per-request context
|
|
339
|
+
|
|
340
|
+
A `ManagedRuntime` owns the service layers. The context argument belongs to one
|
|
341
|
+
request. This executable example seeds an in-memory table, runs a mutation, and
|
|
342
|
+
resolves its nested workspace through the same DynamoDB service.
|
|
343
|
+
|
|
344
|
+
`main.ts`
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
import { Sandbox } from "@model-ts/dynamodb/Sandbox"
|
|
348
|
+
import { Effect, ManagedRuntime } from "effect"
|
|
349
|
+
import { GraphQL, type AppContext } from "./api/graphql.ts"
|
|
350
|
+
import { schema } from "./api/schema.ts"
|
|
351
|
+
import { List, ListId, Workspace, WorkspaceId } from "./store/models.ts"
|
|
352
|
+
|
|
353
|
+
const context: AppContext = {
|
|
354
|
+
requestId: "example-1",
|
|
355
|
+
identity: { userId: "user-1", canWrite: true },
|
|
356
|
+
}
|
|
357
|
+
const runtime = ManagedRuntime.make(Sandbox.layerMemory())
|
|
358
|
+
try {
|
|
359
|
+
const result = await runtime.runPromise(
|
|
360
|
+
Effect.gen(function* () {
|
|
361
|
+
const sandbox = yield* Sandbox
|
|
362
|
+
yield* sandbox.seed(
|
|
363
|
+
new Workspace({ id: WorkspaceId.make("w1"), name: "Product" }),
|
|
364
|
+
new List({
|
|
365
|
+
id: ListId.make("l1"),
|
|
366
|
+
workspaceId: WorkspaceId.make("w1"),
|
|
367
|
+
name: "Launch",
|
|
368
|
+
description: "Draft",
|
|
369
|
+
}),
|
|
370
|
+
)
|
|
371
|
+
return yield* GraphQL.execute(
|
|
372
|
+
schema,
|
|
373
|
+
{
|
|
374
|
+
source: `mutation($id: ID!, $input: ListUpdateInput!) {
|
|
375
|
+
listUpdate(id: $id, input: $input) {
|
|
376
|
+
__typename
|
|
377
|
+
... on List { id name description workspace { name } }
|
|
378
|
+
... on NotFoundError { message }
|
|
379
|
+
... on UserInputError { message field }
|
|
380
|
+
}
|
|
381
|
+
}`,
|
|
382
|
+
variableValues: {
|
|
383
|
+
id: "l1",
|
|
384
|
+
input: { name: "Launch plan", description: null },
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
context,
|
|
388
|
+
)
|
|
389
|
+
}),
|
|
390
|
+
)
|
|
391
|
+
console.log(result.data)
|
|
392
|
+
} finally {
|
|
393
|
+
await runtime.dispose()
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
The success result contains `__typename: "List"`, the new name, a null
|
|
398
|
+
description, and workspace name `"Product"`. A long-running application creates
|
|
399
|
+
its runtime once and disposes it on shutdown. To use a real table, supply
|
|
400
|
+
[`DynamoDB.layer`](../dynamodb/README.md#the-dynamodb-service) with the table and
|
|
401
|
+
cursor configuration instead of a sandbox layer.
|
|
402
|
+
|
|
403
|
+
The remaining examples are additions or alternatives to this app. Export new
|
|
404
|
+
definitions from `api/definitions.ts`; when replacing an object definition,
|
|
405
|
+
export the replacement instead of both versions.
|
|
406
|
+
|
|
407
|
+
## Registration and type safety
|
|
408
|
+
|
|
409
|
+
Registration describes a GraphQL name's model and kind. It does not import or
|
|
410
|
+
construct the corresponding GraphQL definition.
|
|
411
|
+
|
|
412
|
+
| Definition | Registration |
|
|
413
|
+
| ----------------------- | ----------------------------------------------- |
|
|
414
|
+
| Tagged object | `Register<typeof List>`; name comes from `_tag` |
|
|
415
|
+
| Explicitly named object | `Register<typeof Projection, "SearchHit">` |
|
|
416
|
+
| Enum, scalar, or union | `Register<typeof Model, "Name">` |
|
|
417
|
+
| Interface | `Register<typeof Model, "Name", "interface">` |
|
|
418
|
+
|
|
419
|
+
Place the declaration merge beside the definition, targeting the module that
|
|
420
|
+
exports your application's `Types`. Define explicitly named objects with
|
|
421
|
+
`GraphQL.object("SearchHit", Projection, options, fields)`.
|
|
422
|
+
|
|
423
|
+
The checks happen where you can fix the problem:
|
|
424
|
+
|
|
425
|
+
| Problem | Diagnostic location |
|
|
426
|
+
| ------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
427
|
+
| Unknown or unregistered name | The reference or definition, with a registration fix |
|
|
428
|
+
| Wrong property type, resolver result, ID brand, or decoded argument | The field definition |
|
|
429
|
+
| Registered type omitted from the barrel | `GraphQL.schema(definitions)` |
|
|
430
|
+
| Incompatible inherited interface field | Schema assembly, naming the object and field |
|
|
431
|
+
| Definitions from builders with incompatible contexts | Field definition or schema assembly |
|
|
432
|
+
| Missing or wrong request context | The execute/subscribe call |
|
|
433
|
+
| Missing service layer | Running the Effect or Stream |
|
|
434
|
+
| Duplicate runtime name or invalid GraphQL type shape | Schema construction |
|
|
435
|
+
|
|
436
|
+
Output fields are non-null by default. Use `GraphQL.nullable` when the model or resolver
|
|
437
|
+
can return null, undefined, or an `Option`. A real registered name takes precedence
|
|
438
|
+
over a possible derived name such as `LibraryConnection`. Generated connection
|
|
439
|
+
and edge names are reserved only when used; actual collisions still fail assembly.
|
|
440
|
+
|
|
441
|
+
## Objects and fields
|
|
442
|
+
|
|
443
|
+
A field without a resolver reads the property with the same name. A field with a
|
|
444
|
+
resolver receives `(parent, decodedArgs, context)` and returns a value or Effect.
|
|
445
|
+
Use `GraphQL.object(Model, options, fields)` for descriptions and interfaces.
|
|
446
|
+
|
|
447
|
+
| Helper | GraphQL output |
|
|
448
|
+
| ------------------------------------------------------------------------------------------- | ----------------------------------- |
|
|
449
|
+
| `GraphQL.id()`, `GraphQL.string()`, `GraphQL.int()`, `GraphQL.float()`, `GraphQL.boolean()` | Required scalar property |
|
|
450
|
+
| `GraphQL.dateTime()`, `GraphQL.json()` | Required DateTime or JSON property |
|
|
451
|
+
| `GraphQL.nullable.string()` and other nullable scalar helpers | Nullable scalar property |
|
|
452
|
+
| `GraphQL.field("Workspace", resolve)` | Required named type with a resolver |
|
|
453
|
+
| `GraphQL.nullable("Workspace", { resolve })` | Nullable named type |
|
|
454
|
+
| `GraphQL.list("List", { resolve })` | Required list of required items |
|
|
455
|
+
| `GraphQL.list(GraphQL.nullable("List"), { resolve })` | Required list allowing null items |
|
|
456
|
+
| `GraphQL.nullable(GraphQL.list("List"), { resolve })` | Nullable list of required items |
|
|
457
|
+
|
|
458
|
+
For computed scalar fields, use `GraphQL.field` with the scalar name:
|
|
459
|
+
|
|
460
|
+
`examples/fields.ts`
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
import { GraphQL } from "../api/graphql.ts"
|
|
464
|
+
import { List } from "../store/models.ts"
|
|
465
|
+
|
|
466
|
+
export const ListWithSummary = GraphQL.object(List, {
|
|
467
|
+
id: GraphQL.id(),
|
|
468
|
+
name: GraphQL.string({ deprecationReason: "Use displayName." }),
|
|
469
|
+
displayName: GraphQL.field("String", (list) => list.name),
|
|
470
|
+
summary: GraphQL.field("String", {
|
|
471
|
+
description: "A short label for this list.",
|
|
472
|
+
resolve: (list) => `${list.name}: ${list.description ?? "No description"}`,
|
|
473
|
+
}),
|
|
474
|
+
})
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
This is an alternative to the quick start's `ListType`; export one definition per
|
|
478
|
+
object name. Field options include `description`, `deprecationReason`, and
|
|
479
|
+
`authorize`. Authorization can return a boolean or an Effect of a boolean.
|
|
480
|
+
|
|
481
|
+
## Arguments and inputs
|
|
482
|
+
|
|
483
|
+
Arguments are Effect schemas. Decoding preserves branded IDs, nested class
|
|
484
|
+
instances, refinements, and transformations. Use `GraphQL.id(IdSchema)` to expose
|
|
485
|
+
a string-based branded schema as GraphQL `ID` without changing the model schema.
|
|
486
|
+
Without a schema, `GraphQL.id()` is the `ID` output field; each form is rejected
|
|
487
|
+
by the compiler in the other position.
|
|
488
|
+
`GraphQL.input("FilterInput", Schema.Struct(...))` names an input struct;
|
|
489
|
+
`Schema.Class` inputs already supply a name. Inputs need no `Types` registration.
|
|
490
|
+
|
|
491
|
+
GraphQL omission and explicit null have different meanings:
|
|
492
|
+
|
|
493
|
+
| Input schema | Omitted | Explicit `null` |
|
|
494
|
+
| -------------------------------------------------- | -------------------------------- | --------------- |
|
|
495
|
+
| `Schema.String` | Rejected | Rejected |
|
|
496
|
+
| `Schema.optionalKey(Schema.String)` | Accepted | Rejected |
|
|
497
|
+
| `Schema.NullOr(Schema.String)` | Schema decoding requires the key | Accepted |
|
|
498
|
+
| `Schema.optionalKey(Schema.NullOr(Schema.String))` | Accepted | Accepted |
|
|
499
|
+
|
|
500
|
+
GraphQL itself cannot express “omittable but not null” or “required but nullable”;
|
|
501
|
+
Schema decoding enforces those distinctions after GraphQL coercion. For updates,
|
|
502
|
+
model-ts drops undefined values. Omit a field to leave it unchanged; send `null`
|
|
503
|
+
to write an explicit empty value, and allow null in both its input and stored
|
|
504
|
+
model field. Null is a stored value, not a DynamoDB attribute removal.
|
|
505
|
+
|
|
506
|
+
`Schema.Array(S)` requires non-null items. `Schema.Array(Schema.NullOr(S))` allows
|
|
507
|
+
null items; `Schema.NullOr(Schema.Array(S))` allows a null list. Schema descriptions
|
|
508
|
+
on input classes and fields become GraphQL descriptions.
|
|
509
|
+
|
|
510
|
+
Use `GraphQL.default(schema, decodedValue)` for argument defaults:
|
|
511
|
+
|
|
512
|
+
`examples/arguments.ts`
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
import { Schema } from "effect"
|
|
516
|
+
import { GraphQL } from "../api/graphql.ts"
|
|
517
|
+
|
|
518
|
+
export const GreetingQuery = GraphQL.queryField("greeting", "String", {
|
|
519
|
+
args: { name: GraphQL.default(Schema.String, "world") },
|
|
520
|
+
resolve: (_, { name }) => `Hello, ${name}`,
|
|
521
|
+
})
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Defaults are validated and encoded with their schema. An explicit null sent to an
|
|
525
|
+
optional-only input reports its path and suggests omitting the key or using
|
|
526
|
+
`Schema.optionalKey(Schema.NullOr(S))`.
|
|
527
|
+
|
|
528
|
+
## Enums
|
|
529
|
+
|
|
530
|
+
An enum model is a `Schema.Literals` of strings. Register it with an explicit
|
|
531
|
+
name, then choose identity values or map GraphQL names to domain values.
|
|
532
|
+
|
|
533
|
+
`examples/enums.ts`
|
|
534
|
+
|
|
535
|
+
```ts
|
|
536
|
+
import { Schema } from "effect"
|
|
537
|
+
import { GraphQL, type Register } from "../api/graphql.ts"
|
|
538
|
+
|
|
539
|
+
const Scope = Schema.Literals(["user:read", "workspace:write"])
|
|
540
|
+
declare module "../api/graphql.ts" {
|
|
541
|
+
interface Types extends Register<typeof Scope, "Scope"> {}
|
|
542
|
+
}
|
|
543
|
+
export const ScopeType = GraphQL.enum("Scope", Scope, {
|
|
544
|
+
description: "Permissions granted to an API key.",
|
|
545
|
+
values: {
|
|
546
|
+
USER_READ: { value: "user:read", description: "Read the current user." },
|
|
547
|
+
WORKSPACE_WRITE: "workspace:write",
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
export const ScopeQuery = GraphQL.queryField("echoScope", "Scope", {
|
|
551
|
+
args: { scope: Scope },
|
|
552
|
+
resolve: (_, { scope }) => scope,
|
|
553
|
+
})
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Clients send and receive `USER_READ`; resolvers see `"user:read"`. Every model
|
|
557
|
+
literal must have one external name. Duplicate internal values are rejected.
|
|
558
|
+
For identity values such as `ACTIVE` and `ARCHIVED`, omit the `values` option.
|
|
559
|
+
Mapped values can also carry `deprecationReason`.
|
|
560
|
+
|
|
561
|
+
Export both definitions through your schema barrel. In a larger application,
|
|
562
|
+
keep `Scope` in the store and its GraphQL mapping in the API. Arguments and nested
|
|
563
|
+
inputs can use that same model schema directly, including optional, nullable,
|
|
564
|
+
annotated, and list wrappers. Mapping is by schema identity: constructing a new
|
|
565
|
+
literal schema with the same values does not reuse the registration.
|
|
566
|
+
|
|
567
|
+
## Interfaces and unions
|
|
568
|
+
|
|
569
|
+
### Inherit fields from an interface
|
|
570
|
+
|
|
571
|
+
An interface defines a source shape and a reusable field map. Objects declare
|
|
572
|
+
`implements` and inherit its fields, including resolvers and authorization.
|
|
573
|
+
|
|
574
|
+
`examples/interfaces.ts`
|
|
575
|
+
|
|
576
|
+
```ts
|
|
577
|
+
import { Schema } from "effect"
|
|
578
|
+
import { GraphQL, type Register } from "../api/graphql.ts"
|
|
579
|
+
import { List, Workspace } from "../store/models.ts"
|
|
580
|
+
|
|
581
|
+
const Node = Schema.Struct({ id: Schema.String })
|
|
582
|
+
declare module "../api/graphql.ts" {
|
|
583
|
+
interface Types extends Register<typeof Node, "Node", "interface"> {}
|
|
584
|
+
}
|
|
585
|
+
export const NodeType = GraphQL.interface("Node", Node, {
|
|
586
|
+
id: GraphQL.id(),
|
|
587
|
+
})
|
|
588
|
+
export const ListWithNode = GraphQL.object(
|
|
589
|
+
List,
|
|
590
|
+
{ implements: ["Node"] },
|
|
591
|
+
{ name: GraphQL.string() },
|
|
592
|
+
)
|
|
593
|
+
export const WorkspaceWithNode = GraphQL.object(
|
|
594
|
+
Workspace,
|
|
595
|
+
{ implements: ["Node"] },
|
|
596
|
+
{ name: GraphQL.string() },
|
|
597
|
+
)
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
Replace the original object definitions with these variants and export
|
|
601
|
+
`NodeType`. Both objects inherit `id`. An explicit object field replaces the
|
|
602
|
+
inherited field, including its resolver and authorization; use this to adapt an
|
|
603
|
+
interface to a different source representation. Services used by inherited fields
|
|
604
|
+
still become schema requirements.
|
|
605
|
+
|
|
606
|
+
For an interface description or custom type resolver, use
|
|
607
|
+
`GraphQL.interface(name, model, { description, resolveType }, fields)`.
|
|
608
|
+
Without `resolveType`, abstract values resolve through `__typename`, then `_tag`.
|
|
609
|
+
The model-ts classes above already carry their tags.
|
|
610
|
+
|
|
611
|
+
### Return one of several objects
|
|
612
|
+
|
|
613
|
+
Tagged model unions infer their member names:
|
|
614
|
+
|
|
615
|
+
`examples/unions.ts`
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
import { Schema } from "effect"
|
|
619
|
+
import { GraphQL, type Register } from "../api/graphql.ts"
|
|
620
|
+
import { List, Workspace } from "../store/models.ts"
|
|
621
|
+
|
|
622
|
+
const SearchResult = Schema.Union([List, Workspace])
|
|
623
|
+
declare module "../api/graphql.ts" {
|
|
624
|
+
interface Types extends Register<typeof SearchResult, "SearchResult"> {}
|
|
625
|
+
}
|
|
626
|
+
export const SearchResultType = GraphQL.union("SearchResult", SearchResult)
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
For untagged classes or projections, register each object with an explicit name,
|
|
630
|
+
then pass `members` in the same order as the schemas in `Schema.Union`. A custom
|
|
631
|
+
`resolveType` selects a registered member name or returns null:
|
|
632
|
+
|
|
633
|
+
`examples/projections.ts`
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
import { Schema } from "effect"
|
|
637
|
+
import { GraphQL, type Register } from "../api/graphql.ts"
|
|
638
|
+
|
|
639
|
+
const Project = Schema.Struct({
|
|
640
|
+
kind: Schema.Literal("project"),
|
|
641
|
+
title: Schema.String,
|
|
642
|
+
})
|
|
643
|
+
const Person = Schema.Struct({
|
|
644
|
+
kind: Schema.Literal("person"),
|
|
645
|
+
name: Schema.String,
|
|
646
|
+
})
|
|
647
|
+
const Hit = Schema.Union([Project, Person])
|
|
648
|
+
declare module "../api/graphql.ts" {
|
|
649
|
+
interface Types
|
|
650
|
+
extends
|
|
651
|
+
Register<typeof Project, "ProjectHit">,
|
|
652
|
+
Register<typeof Person, "PersonHit">,
|
|
653
|
+
Register<typeof Hit, "Hit"> {}
|
|
654
|
+
}
|
|
655
|
+
export const ProjectType = GraphQL.object(
|
|
656
|
+
"ProjectHit",
|
|
657
|
+
Project,
|
|
658
|
+
{},
|
|
659
|
+
{ title: GraphQL.string() },
|
|
660
|
+
)
|
|
661
|
+
export const PersonType = GraphQL.object(
|
|
662
|
+
"PersonHit",
|
|
663
|
+
Person,
|
|
664
|
+
{},
|
|
665
|
+
{ name: GraphQL.string() },
|
|
666
|
+
)
|
|
667
|
+
export const HitType = GraphQL.union("Hit", Hit, {
|
|
668
|
+
members: ["ProjectHit", "PersonHit"],
|
|
669
|
+
resolveType: (value) =>
|
|
670
|
+
value.kind === "project" ? "ProjectHit" : "PersonHit",
|
|
671
|
+
})
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Union membership and resolver return types are checked against the registered
|
|
675
|
+
models. GraphQL metadata need not be added to the projection values.
|
|
676
|
+
|
|
677
|
+
## Mutations and typed results
|
|
678
|
+
|
|
679
|
+
`GraphQL.mutationField(name, type, config)` defines one root field in the same way as
|
|
680
|
+
`GraphQL.queryField(name, type, config)`. A field's `errors: [ErrorClass, ...]` turns
|
|
681
|
+
expected typed failures into ordinary GraphQL data. Clients inspect `__typename` and select the corresponding fragment.
|
|
682
|
+
Only errors declared on that field become its result members.
|
|
683
|
+
|
|
684
|
+
The quick start generates this contract:
|
|
685
|
+
|
|
686
|
+
```graphql
|
|
687
|
+
union ListUpdateResult = List | NotFoundError | UserInputError
|
|
688
|
+
|
|
689
|
+
type Mutation {
|
|
690
|
+
listUpdate(id: ID!, input: ListUpdateInput!): ListUpdateResult!
|
|
691
|
+
}
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
Use `GraphQL.nullable("List", { errors, resolve })` for a nullable result and to allow a
|
|
695
|
+
null success. Its nullability also determines where GraphQL contains an
|
|
696
|
+
execution or authorization failure. A union success reference, such as
|
|
697
|
+
`"SearchResult"` above, contributes all its object members to the generated
|
|
698
|
+
result. Its type resolver is reused.
|
|
699
|
+
|
|
700
|
+
The `result` option controls the wrapper's name and description:
|
|
701
|
+
|
|
702
|
+
`examples/results.ts`
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
import { Effect } from "effect"
|
|
706
|
+
import { GraphQL } from "../api/graphql.ts"
|
|
707
|
+
import { NotFoundError } from "../api/mutation/list-update.ts"
|
|
708
|
+
|
|
709
|
+
export const OptionalUpdate = GraphQL.mutationField(
|
|
710
|
+
"optionalUpdate",
|
|
711
|
+
GraphQL.nullable("List"),
|
|
712
|
+
{
|
|
713
|
+
errors: [NotFoundError],
|
|
714
|
+
result: {
|
|
715
|
+
name: "OptionalUpdateResult",
|
|
716
|
+
description: "The updated list or a domain error.",
|
|
717
|
+
},
|
|
718
|
+
resolve: () =>
|
|
719
|
+
Effect.fail(new NotFoundError({ message: "List not found" })),
|
|
720
|
+
},
|
|
721
|
+
)
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
`result: {}` also creates a wrapper when there are no errors, including a
|
|
725
|
+
one-member result union. Default mutation result names are `<Field>Result`;
|
|
726
|
+
non-mutation names include the parent type, such as `QueryUpdateResult`.
|
|
727
|
+
|
|
728
|
+
To share an `Error` interface, register the error class as an object and define
|
|
729
|
+
it with `implements`. The generated union reuses that object definition:
|
|
730
|
+
|
|
731
|
+
`examples/error-interface.ts`
|
|
732
|
+
|
|
733
|
+
```ts
|
|
734
|
+
import { Schema } from "effect"
|
|
735
|
+
import { GraphQL, type Register } from "../api/graphql.ts"
|
|
736
|
+
import { NotFoundError } from "../api/mutation/list-update.ts"
|
|
737
|
+
|
|
738
|
+
const ErrorModel = Schema.Struct({ message: Schema.String })
|
|
739
|
+
declare module "../api/graphql.ts" {
|
|
740
|
+
interface Types
|
|
741
|
+
extends
|
|
742
|
+
Register<typeof ErrorModel, "Error", "interface">,
|
|
743
|
+
Register<typeof NotFoundError> {}
|
|
744
|
+
}
|
|
745
|
+
export const ErrorType = GraphQL.interface("Error", ErrorModel, {
|
|
746
|
+
message: GraphQL.string(),
|
|
747
|
+
})
|
|
748
|
+
export const NotFoundType = GraphQL.object(
|
|
749
|
+
NotFoundError,
|
|
750
|
+
{ implements: ["Error"] },
|
|
751
|
+
{},
|
|
752
|
+
)
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
Registered error-object fields receive the original error class instance as their
|
|
756
|
+
parent, including in authorization callbacks. Getters and methods therefore work
|
|
757
|
+
in computed fields. Only the declared GraphQL fields are exposed; incidental
|
|
758
|
+
properties, stacks, and causes are not copied into the response.
|
|
759
|
+
|
|
760
|
+
An error class declared internal in the builder policy cannot also be exposed as
|
|
761
|
+
a result member. Translate infrastructure failures into domain errors instead.
|
|
762
|
+
|
|
763
|
+
## Connections and pagination
|
|
764
|
+
|
|
765
|
+
`GraphQL.connection("List", options)` generates `edges`, `node`, and `pageInfo` fields
|
|
766
|
+
with Relay connection shapes. Nodes can be registered objects, interfaces, or
|
|
767
|
+
unions. A storage-backed connection returns its page directly:
|
|
768
|
+
|
|
769
|
+
`examples/connections.ts`
|
|
770
|
+
|
|
771
|
+
```ts
|
|
772
|
+
import { GraphQL } from "../api/graphql.ts"
|
|
773
|
+
import { List, WorkspaceId } from "../store/models.ts"
|
|
774
|
+
|
|
775
|
+
export const ListsQuery = GraphQL.queryField(
|
|
776
|
+
"lists",
|
|
777
|
+
GraphQL.connection("List"),
|
|
778
|
+
{
|
|
779
|
+
args: { workspaceId: GraphQL.id(WorkspaceId) },
|
|
780
|
+
authorize: (_, __, { identity }) => identity !== null,
|
|
781
|
+
resolve: (_, { workspaceId, ...page }) =>
|
|
782
|
+
List.paginate(page, {
|
|
783
|
+
IndexName: "GSI1",
|
|
784
|
+
KeyConditionExpression: "GSI1PK = :pk",
|
|
785
|
+
ExpressionAttributeValues: { ":pk": `WORKSPACE#${workspaceId}` },
|
|
786
|
+
}),
|
|
787
|
+
},
|
|
788
|
+
)
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
Clients can request:
|
|
792
|
+
|
|
793
|
+
```graphql
|
|
794
|
+
query {
|
|
795
|
+
lists(workspaceId: "w1", first: 20) {
|
|
796
|
+
edges {
|
|
797
|
+
cursor
|
|
798
|
+
node {
|
|
799
|
+
id
|
|
800
|
+
name
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
pageInfo {
|
|
804
|
+
hasNextPage
|
|
805
|
+
hasPreviousPage
|
|
806
|
+
startCursor
|
|
807
|
+
endCursor
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
Choose the pagination mode according to the source:
|
|
814
|
+
|
|
815
|
+
| Option | Resolver returns | Defaults and cursors |
|
|
816
|
+
| --------- | ------------------------------------------ | ------------------------------------------------------------------------------- |
|
|
817
|
+
| `resolve` | A `Connection<Node>` page or Effect of one | Storage owns defaults and opaque cursors. `List.paginate` results pass through. |
|
|
818
|
+
| `nodes` | All matching nodes or Effect of that array | Builder slices the array and creates base64 offset cursors. |
|
|
819
|
+
|
|
820
|
+
For `nodes`, `createBuilder({ defaultPageSize: 20, maxPageSize: 100 })` controls
|
|
821
|
+
slicing. Those are the defaults; the default size is capped by the maximum.
|
|
822
|
+
A before-only request slices backward. With `resolve`, the builder does not
|
|
823
|
+
insert a size or interpret the cursor: a DynamoDB layer's `pagination.default`
|
|
824
|
+
and encrypted cursor format continue to apply. Array offsets and DynamoDB
|
|
825
|
+
cursors are not interchangeable.
|
|
826
|
+
|
|
827
|
+
The builder validates explicit `first` and `last` in both modes. Zero is an empty
|
|
828
|
+
page; null means omitted. Negative or excessive sizes, conflicting directions,
|
|
829
|
+
and simultaneous `before`/`after` produce `BAD_USER_INPUT`. This behavior is the
|
|
830
|
+
same with synchronous and Effect authorization. Storage-level failures, including
|
|
831
|
+
DynamoDB `PaginationError`, follow the application's configured error policy.
|
|
832
|
+
The four pagination argument names are reserved; additional `args` use Schema
|
|
833
|
+
decoding as usual.
|
|
834
|
+
|
|
835
|
+
`totalCount(parent, args, context)` adds `totalCount: Int!` and can return a number
|
|
836
|
+
or Effect. It runs **only when that field is selected**, including selections
|
|
837
|
+
through fragments and directives. It receives the decoded custom arguments and
|
|
838
|
+
request context, and its service requirements are collected with the page's.
|
|
839
|
+
|
|
840
|
+
Uncounted connections default to `<Node>Connection`; counted ones default to
|
|
841
|
+
`<Node>CountedConnection`. `name` overrides the connection name and `edgeName`
|
|
842
|
+
overrides the default `<Node>Edge` independently. A real `LibraryConnection`
|
|
843
|
+
object can coexist with a generated `LibraryDirectoryConnection` by naming the
|
|
844
|
+
latter explicitly. Assembly rejects incompatible reuse of generated names.
|
|
845
|
+
Derived references such as `GraphQL.field("ListEdge", resolve)` retain the node type.
|
|
846
|
+
|
|
847
|
+
## Scalars
|
|
848
|
+
|
|
849
|
+
The builder supplies `ID`, `String`, `Boolean`, `Int`, `Float`, `DateTime`, and
|
|
850
|
+
`JSON`. Use the exported input schemas when the wire scalar is significant:
|
|
851
|
+
|
|
852
|
+
`examples/scalars.ts`
|
|
853
|
+
|
|
854
|
+
```ts
|
|
855
|
+
import { DateTime, JSONValue } from "@model-ts/graphql"
|
|
856
|
+
import { GraphQL } from "../api/graphql.ts"
|
|
857
|
+
|
|
858
|
+
export const EchoTimeQuery = GraphQL.queryField("echoTime", "DateTime", {
|
|
859
|
+
args: { at: DateTime },
|
|
860
|
+
resolve: (_, { at }) => at,
|
|
861
|
+
})
|
|
862
|
+
export const EchoJSONQuery = GraphQL.queryField("echoJSON", "JSON", {
|
|
863
|
+
args: { value: JSONValue },
|
|
864
|
+
resolve: (_, { value }) => value,
|
|
865
|
+
})
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
`DateTime` inputs deliver normalized ISO strings. `Schema.Date` inputs deliver
|
|
869
|
+
`Date` objects, including inside input classes and lists. For an explicit codec,
|
|
870
|
+
use `GraphQL.input("DateTime", Schema.DateFromString)`. DateTime output accepts a
|
|
871
|
+
valid Date or parseable date string and serializes to ISO.
|
|
872
|
+
|
|
873
|
+
`JSONValue` has type `unknown`: variables and outputs follow the permissive
|
|
874
|
+
`graphql-type-json` convention. Literal JSON rejects unquoted enum values. For
|
|
875
|
+
stricter validation, use `Schema.Json.annotate({ graphqlType: "JSON" })`.
|
|
876
|
+
|
|
877
|
+
Custom scalars pair a registered name with a Schema codec:
|
|
878
|
+
|
|
879
|
+
`examples/custom-scalar.ts`
|
|
880
|
+
|
|
881
|
+
```ts
|
|
882
|
+
import { Schema } from "effect"
|
|
883
|
+
import { GraphQL, type Register } from "../api/graphql.ts"
|
|
884
|
+
|
|
885
|
+
const Slug = Schema.String.check(Schema.isPattern(/^[a-z0-9-]+$/)).pipe(
|
|
886
|
+
Schema.brand("Slug"),
|
|
887
|
+
)
|
|
888
|
+
declare module "../api/graphql.ts" {
|
|
889
|
+
interface Types extends Register<typeof Slug, "Slug"> {}
|
|
890
|
+
}
|
|
891
|
+
export const SlugType = GraphQL.scalar(
|
|
892
|
+
"Slug",
|
|
893
|
+
Slug.annotate({
|
|
894
|
+
description: "Lowercase letters, numbers, and hyphens.",
|
|
895
|
+
}),
|
|
896
|
+
)
|
|
897
|
+
export const SlugQuery = GraphQL.queryField("echoSlug", "Slug", {
|
|
898
|
+
args: { slug: SlugType.schema },
|
|
899
|
+
resolve: (_, { slug }) => slug,
|
|
900
|
+
})
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
The codec validates inputs and encodes outputs; use the definition's `.schema`
|
|
904
|
+
for custom scalar arguments. `description` and `specifiedByURL` annotations
|
|
905
|
+
become scalar metadata. To keep an existing implementation, pass a matching
|
|
906
|
+
`GraphQLScalarType` as the third argument to `GraphQL.scalar`. Its name must match,
|
|
907
|
+
and the Schema codec must accept what its parser returns. The adopted scalar is
|
|
908
|
+
defensively cloned: its coercion behavior and metadata are preserved, but its
|
|
909
|
+
object identity is not.
|
|
910
|
+
|
|
911
|
+
Expected codec input failures use `BAD_USER_INPUT` with a generic message such as
|
|
912
|
+
`Invalid value for Slug`. Exceptions thrown by custom or adopted scalar callbacks
|
|
913
|
+
follow the public/internal error policy for both inline literals and variables.
|
|
914
|
+
Declared public failures retain their safe fields and code; unexpected causes are
|
|
915
|
+
masked and sent to `ErrorReporter`. Ordinary graphql-js document and built-in
|
|
916
|
+
input diagnostics remain intact.
|
|
917
|
+
|
|
918
|
+
Registering and exporting a scalar named `DateTime` or `JSON` replaces that
|
|
919
|
+
built-in implementation. The five standard graphql-js scalars remain reserved.
|
|
920
|
+
|
|
921
|
+
## Subscriptions
|
|
922
|
+
|
|
923
|
+
Define a subscription with `GraphQL.subscriptionField(name, type, config)` and
|
|
924
|
+
a `subscribe` callback that returns an Effect `Stream`. Its `authorize` callback
|
|
925
|
+
runs for each delivered value, with that value as its parent, and returns a boolean or an Effect. Both
|
|
926
|
+
callbacks receive decoded arguments and request context. Their service requirements,
|
|
927
|
+
along with nested resolver requirements, are retained in the built schema type.
|
|
928
|
+
|
|
929
|
+
`examples/subscriptions.ts`
|
|
930
|
+
|
|
931
|
+
```ts
|
|
932
|
+
import { Stream } from "effect"
|
|
933
|
+
import { GraphQL } from "../api/graphql.ts"
|
|
934
|
+
import { List, ListId } from "../store/models.ts"
|
|
935
|
+
|
|
936
|
+
export const ListSubscription = GraphQL.subscriptionField(
|
|
937
|
+
"listChanged",
|
|
938
|
+
"List",
|
|
939
|
+
{
|
|
940
|
+
args: { id: GraphQL.id(ListId) },
|
|
941
|
+
// A finite stream for this example; replace it with your event source.
|
|
942
|
+
subscribe: (_, { id }) => Stream.fromEffect(List.load(List.key(id))),
|
|
943
|
+
authorize: (_, __, { identity }) => identity !== null,
|
|
944
|
+
},
|
|
945
|
+
)
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
The third argument to `GraphQL.subscriptionField` must have `subscribe` and
|
|
949
|
+
cannot have `resolve`. `subscribe` is rejected on object, interface, query, and
|
|
950
|
+
mutation fields, including reused configs. Nullable and list subscriptions use
|
|
951
|
+
config-less refs in the second argument, such as `GraphQL.nullable("List")`,
|
|
952
|
+
`GraphQL.list("List")`, or `GraphQL.nullable(GraphQL.list("List"))`. The stream
|
|
953
|
+
emits the declared nullable or list value;
|
|
954
|
+
`authorize` receives it before output normalization.
|
|
955
|
+
|
|
956
|
+
Export the subscription in the schema barrel with `export *`. Consume
|
|
957
|
+
`GraphQL.subscribe(schema, request, context)` as a
|
|
958
|
+
Stream, for example with `Stream.runCollect` in the application's runtime. Put
|
|
959
|
+
subscription setup checks in the source callback; delivery authorization receives
|
|
960
|
+
the emitted object as its parent. Setup and terminal stream failures use the
|
|
961
|
+
same declared error policy as queries and mutations. Cancellation interrupts the
|
|
962
|
+
source and in-flight event work, including authorization and nested resolvers,
|
|
963
|
+
and waits for their finalizers before completing.
|
|
964
|
+
|
|
965
|
+
Repeated spreads such as `subscription { ...Changed ...Changed }` are valid
|
|
966
|
+
when `Changed` selects one root response key. Validation merges repeated fields,
|
|
967
|
+
named fragments, and inline fragments by that key.
|
|
968
|
+
Different aliases count as different keys, even when they select the same field.
|
|
969
|
+
The root field must not be an introspection field.
|
|
970
|
+
|
|
971
|
+
Root `@skip` and `@include` directives are rejected without evaluating their
|
|
972
|
+
arguments, including on root fragment spreads and inline fragments. Conditional
|
|
973
|
+
selections inside the root field's payload remain valid and use the operation's
|
|
974
|
+
variables. Invalid root selections return public validation errors before the
|
|
975
|
+
subscription source runs.
|
|
976
|
+
|
|
977
|
+
## Request context and layers
|
|
978
|
+
|
|
979
|
+
Use **context** for request data: identity, tenant, locale, and request ID. Use
|
|
980
|
+
**layers** for services and their lifetimes: DynamoDB, HTTP clients, caches, and
|
|
981
|
+
telemetry. Resolvers receive context as their third argument and access services
|
|
982
|
+
through Effect. Never capture a request's identity in a shared runtime layer.
|
|
983
|
+
|
|
984
|
+
`BuiltSchema<R, C>` tracks both. The context type is carried through fields,
|
|
985
|
+
definitions, schema assembly, execution, and subscriptions. Services used by
|
|
986
|
+
resolvers, authorization, inherited fields, and total counts contribute to `R`.
|
|
987
|
+
Supply those services when running the operation; you do not have to annotate the
|
|
988
|
+
schema's requirement union yourself.
|
|
989
|
+
|
|
990
|
+
`GraphQL.execute` returns an Effect of an `ExecutionResult`. `GraphQL.subscribe`
|
|
991
|
+
returns a Stream of those results. Both take
|
|
992
|
+
`{ source, variableValues?, operationName? }` and require the application's context
|
|
993
|
+
when its type requires one. They apply the error policy and retain the graphql-js
|
|
994
|
+
result shape, including GraphQLError objects. Public failures are represented in
|
|
995
|
+
the result rather than the returned Effect's error channel.
|
|
996
|
+
|
|
997
|
+
### Formatted execution results
|
|
998
|
+
|
|
999
|
+
Use the package-level `executor(schema)` for transport-ready results. Its
|
|
1000
|
+
`execute` and `subscribe` methods accept a parsed document and return an Effect
|
|
1001
|
+
or Stream of `FormattedExecutionResult`: plain, JSON-safe data and error records.
|
|
1002
|
+
The executor declares `validation: "executor"`: it runs standard schema validation
|
|
1003
|
+
before execution or subscription setup, so literal scalar failures receive the same
|
|
1004
|
+
public/internal policy as variable coercion failures. Transports should skip their
|
|
1005
|
+
own standard schema validation for this capability, retaining parsing, operation
|
|
1006
|
+
selection, request limits and transport-specific rules.
|
|
1007
|
+
|
|
1008
|
+
The executor owns result formatting. Transports must preserve its public
|
|
1009
|
+
messages, extension codes, locations, and paths when sending the result.
|
|
1010
|
+
|
|
1011
|
+
`examples/executor.ts`
|
|
1012
|
+
|
|
1013
|
+
```ts
|
|
1014
|
+
import { executor } from "@model-ts/graphql"
|
|
1015
|
+
import { parse } from "graphql"
|
|
1016
|
+
import { schema } from "../api/schema.ts"
|
|
1017
|
+
|
|
1018
|
+
const query = "query($id: ID!) { list(id: $id) { id name } }"
|
|
1019
|
+
export const lookup = executor(schema).execute({
|
|
1020
|
+
document: parse(query),
|
|
1021
|
+
request: { query, variables: { id: "l1" } },
|
|
1022
|
+
context: {
|
|
1023
|
+
requestId: "lookup-1",
|
|
1024
|
+
identity: { userId: "user-1", canWrite: false },
|
|
1025
|
+
},
|
|
1026
|
+
})
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
Run `lookup` through the application's `ManagedRuntime`. Parsed requests may also
|
|
1030
|
+
supply `operationName` and `extensions` in `request`; the supplied document is
|
|
1031
|
+
what executes. The same interface's `subscribe` method produces formatted results
|
|
1032
|
+
for subscription deliveries.
|
|
1033
|
+
|
|
1034
|
+
If formatting fails, for example because a JSON scalar returns a cycle or a
|
|
1035
|
+
BigInt, the executor reports the original cause through `ErrorReporter` and
|
|
1036
|
+
returns a JSON-safe `INTERNAL_SERVER_ERROR` response. Result formatting failures
|
|
1037
|
+
always use that internal fallback. Direct `execute`/`subscribe` retain their
|
|
1038
|
+
`ExecutionResult` shape and do not perform this JSON formatting step.
|
|
1039
|
+
|
|
1040
|
+
## Error policy and reporting
|
|
1041
|
+
|
|
1042
|
+
There are three ways an expected failure can reach a client:
|
|
1043
|
+
|
|
1044
|
+
| Configuration | Response |
|
|
1045
|
+
| -------------------------------- | ---------------------------------------------------------- |
|
|
1046
|
+
| Field `errors: [NotFoundError]` | A typed result-union member in `data` |
|
|
1047
|
+
| Builder `public: [AccessDenied]` | A GraphQL field error with declared safe fields and a code |
|
|
1048
|
+
| `authorize` returns false | A GraphQL field error with code `FORBIDDEN` |
|
|
1049
|
+
|
|
1050
|
+
An authorization callback must return true to permit execution. False produces
|
|
1051
|
+
`You don't have permission to access field "<field>"`. Returning an Effect of a
|
|
1052
|
+
boolean follows the same policy. Authorization failures are field errors, not
|
|
1053
|
+
members of a mutation's result union.
|
|
1054
|
+
|
|
1055
|
+
Choose the public error code exposed to clients:
|
|
1056
|
+
|
|
1057
|
+
`examples/error-policy.ts`
|
|
1058
|
+
|
|
1059
|
+
```ts
|
|
1060
|
+
import { createBuilder, ErrorReporter } from "@model-ts/graphql"
|
|
1061
|
+
import { Cause, Effect, Layer, Schema } from "effect"
|
|
1062
|
+
import type { AppContext, Types } from "../api/graphql.ts"
|
|
1063
|
+
|
|
1064
|
+
class AccessDenied extends Schema.TaggedError<AccessDenied>()("AccessDenied", {
|
|
1065
|
+
message: Schema.String,
|
|
1066
|
+
}) {}
|
|
1067
|
+
export const GraphQL = createBuilder<Types, AppContext>().withErrorPolicy({
|
|
1068
|
+
public: [AccessDenied],
|
|
1069
|
+
publicCodes: { AccessDenied: "ACCESS_DENIED" },
|
|
1070
|
+
})
|
|
1071
|
+
export const Reporting = Layer.succeed(ErrorReporter, {
|
|
1072
|
+
report: ({ cause, coordinate, phase }) =>
|
|
1073
|
+
Effect.logError(
|
|
1074
|
+
"GraphQL operation failed",
|
|
1075
|
+
{ coordinate, phase },
|
|
1076
|
+
Cause.pretty(cause),
|
|
1077
|
+
),
|
|
1078
|
+
})
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
Apply the policy before constructing fields and definitions with the builder. A
|
|
1082
|
+
public error's default code is its tag; `publicCodes` overrides it. Only declared
|
|
1083
|
+
Schema fields are exposed, not incidental properties or stacks. Keep internal
|
|
1084
|
+
errors in `internal`; unclassified resolver failures and defects are masked as
|
|
1085
|
+
`Internal server error` with code `INTERNAL_SERVER_ERROR`. A code attached to an
|
|
1086
|
+
arbitrary thrown exception does not make it public. This policy also applies to
|
|
1087
|
+
custom scalar callbacks and abstract-type resolution. Ordinary graphql-js
|
|
1088
|
+
document diagnostics remain available to clients.
|
|
1089
|
+
|
|
1090
|
+
Provide `Reporting` alongside the application's service layers, for example with
|
|
1091
|
+
`Layer.mergeAll(Sandbox.layerMemory(), Reporting)`. The optional `ErrorReporter`
|
|
1092
|
+
receives the original Effect cause, field coordinate, and phase for masked
|
|
1093
|
+
failures. A reporter failure cannot replace the client error. Resolver and
|
|
1094
|
+
authorization work is traced under the GraphQL execution spans.
|
|
1095
|
+
|
|
1096
|
+
## Testing
|
|
1097
|
+
|
|
1098
|
+
Use `@model-ts/graphql/Testing` to run real operations through the same schema
|
|
1099
|
+
and layers as the application. Results are plain records with GraphQL errors
|
|
1100
|
+
normalized, so they work directly with `toMatchSnapshot` and `toMatchInlineSnapshot`.
|
|
1101
|
+
|
|
1102
|
+
```sh
|
|
1103
|
+
npm install --save-dev @effect/vitest vitest
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
Bind the schema to a default request context once. The factory's options become
|
|
1107
|
+
the options of every request, so each test states only what differs:
|
|
1108
|
+
|
|
1109
|
+
`test/client.ts`
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
import * as Testing from "@model-ts/graphql/Testing"
|
|
1113
|
+
import type { AppContext } from "../api/graphql.ts"
|
|
1114
|
+
import { schema } from "../api/schema.ts"
|
|
1115
|
+
|
|
1116
|
+
export { graphql } from "@model-ts/graphql/Testing"
|
|
1117
|
+
export const { request, subscribe } = Testing.client(schema, {
|
|
1118
|
+
context: (options: { identity?: AppContext["identity"] }) => ({
|
|
1119
|
+
requestId: "test",
|
|
1120
|
+
identity: options.identity ?? null,
|
|
1121
|
+
}),
|
|
1122
|
+
})
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
`test/list-update.test.ts`
|
|
1126
|
+
|
|
1127
|
+
```ts
|
|
1128
|
+
import { expect, layer } from "@effect/vitest"
|
|
1129
|
+
import { Sandbox } from "@model-ts/dynamodb/Sandbox"
|
|
1130
|
+
import { Effect } from "effect"
|
|
1131
|
+
import { List, ListId, WorkspaceId } from "../store/models.ts"
|
|
1132
|
+
import { graphql, request } from "./client.ts"
|
|
1133
|
+
|
|
1134
|
+
const MUTATION = graphql`
|
|
1135
|
+
mutation ListUpdate($id: ID!, $input: ListUpdateInput!) {
|
|
1136
|
+
listUpdate(id: $id, input: $input) {
|
|
1137
|
+
__typename
|
|
1138
|
+
... on List {
|
|
1139
|
+
id
|
|
1140
|
+
name
|
|
1141
|
+
}
|
|
1142
|
+
... on NotFoundError {
|
|
1143
|
+
message
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
`
|
|
1148
|
+
const identity = { userId: "user-1", canWrite: true }
|
|
1149
|
+
|
|
1150
|
+
layer(Sandbox.layerMemory())((it) => {
|
|
1151
|
+
it.effect("renames a list", () =>
|
|
1152
|
+
Effect.gen(function* () {
|
|
1153
|
+
const sandbox = yield* Sandbox
|
|
1154
|
+
yield* sandbox.seed(
|
|
1155
|
+
new List({
|
|
1156
|
+
id: ListId.make("l1"),
|
|
1157
|
+
workspaceId: WorkspaceId.make("w1"),
|
|
1158
|
+
name: "Launch",
|
|
1159
|
+
}),
|
|
1160
|
+
)
|
|
1161
|
+
const variables = { id: "l1", input: { name: "Ship" } }
|
|
1162
|
+
expect(yield* request(MUTATION, { identity, variables }))
|
|
1163
|
+
.toMatchInlineSnapshot(`
|
|
1164
|
+
{
|
|
1165
|
+
"data": {
|
|
1166
|
+
"listUpdate": {
|
|
1167
|
+
"__typename": "List",
|
|
1168
|
+
"id": "l1",
|
|
1169
|
+
"name": "Ship",
|
|
1170
|
+
},
|
|
1171
|
+
},
|
|
1172
|
+
}
|
|
1173
|
+
`)
|
|
1174
|
+
}),
|
|
1175
|
+
)
|
|
1176
|
+
})
|
|
1177
|
+
```
|
|
1178
|
+
|
|
1179
|
+
`request(source, options?)` returns an Effect and `subscribe` returns a Stream;
|
|
1180
|
+
both also accept `variables` and `operationName`. When the factory's options have a
|
|
1181
|
+
required key, every call must pass it; a schema without request context needs no
|
|
1182
|
+
factory. The factory may return an Effect to read services such as `Sandbox`, and
|
|
1183
|
+
those services join the schema's requirements, so a missing layer is still a
|
|
1184
|
+
compiler error.
|
|
1185
|
+
|
|
1186
|
+
The `graphql` tag interpolates values and removes ignored characters with
|
|
1187
|
+
graphql-js `stripIgnoredCharacters`, leaving string literals intact. Snapshots and error
|
|
1188
|
+
locations therefore stay the same when a test is reformatted, and editors still
|
|
1189
|
+
highlight the tagged document.
|
|
1190
|
+
|
|
1191
|
+
A file-level layer shares its table within that test group. For a fresh table per
|
|
1192
|
+
test, use `it.effect` and provide `Sandbox.layerMemory()` on the individual Effect.
|
|
1193
|
+
Replace the memory layer with `Sandbox.layerLocal()` to run the same tests against
|
|
1194
|
+
DynamoDB Local at `http://localhost:8000`. Allow time for real table creation and
|
|
1195
|
+
cleanup in your test and hook timeouts. `Sandbox.seed`, `snapshot`, and `diff` let
|
|
1196
|
+
tests assert storage changes as well as GraphQL responses.
|
|
1197
|
+
|
|
1198
|
+
`Testing.execute(schema, { source, variableValues?, operationName? }, context)` and
|
|
1199
|
+
`Testing.subscribe` remain the low-level API for an explicit context;
|
|
1200
|
+
`Testing.snapshot(result)` normalizes an already executed, serializable result. Use
|
|
1201
|
+
the executor when testing the reported serialization-failure response itself.
|
|
1202
|
+
|
|
1203
|
+
## Calling Promise APIs from resolvers
|
|
1204
|
+
|
|
1205
|
+
Resolvers return values or Effects. Wrap asynchronous dependencies with
|
|
1206
|
+
`Effect.tryPromise`, give failures a typed error, and classify it in the
|
|
1207
|
+
application policy:
|
|
1208
|
+
|
|
1209
|
+
`examples/promises.ts`
|
|
1210
|
+
|
|
1211
|
+
```ts
|
|
1212
|
+
import { createBuilder } from "@model-ts/graphql"
|
|
1213
|
+
import { Effect, Schema } from "effect"
|
|
1214
|
+
|
|
1215
|
+
class UpstreamError extends Schema.TaggedError<UpstreamError>()(
|
|
1216
|
+
"UpstreamError",
|
|
1217
|
+
{
|
|
1218
|
+
cause: Schema.Unknown,
|
|
1219
|
+
},
|
|
1220
|
+
) {}
|
|
1221
|
+
const GraphQL = createBuilder<{}>().withErrorPolicy({
|
|
1222
|
+
internal: [UpstreamError],
|
|
1223
|
+
})
|
|
1224
|
+
export const RemoteQuery = GraphQL.queryField("status", "String", {
|
|
1225
|
+
resolve: () =>
|
|
1226
|
+
Effect.tryPromise({
|
|
1227
|
+
try: async (signal) => {
|
|
1228
|
+
const response = await fetch("https://example.com/status", { signal })
|
|
1229
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
1230
|
+
return response.text()
|
|
1231
|
+
},
|
|
1232
|
+
catch: (cause) => new UpstreamError({ cause }),
|
|
1233
|
+
}),
|
|
1234
|
+
})
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
For an existing remote client, put the client in an Effect service layer and
|
|
1238
|
+
access it from the resolver. Direct Promise returns are rejected by the typed
|
|
1239
|
+
field API.
|
|
1240
|
+
|
|
1241
|
+
## API summary
|
|
1242
|
+
|
|
1243
|
+
The package root exports named functions and types, plus a `GraphQL` namespace.
|
|
1244
|
+
The quick start calls its application-specific builder `GraphQL`; package-level
|
|
1245
|
+
functions such as `executor` are separate exports, not builder methods.
|
|
1246
|
+
|
|
1247
|
+
| API | Purpose |
|
|
1248
|
+
| ------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
1249
|
+
| `createBuilder<Types, Context>(options?)` | Create definitions and field helpers for one registry and context |
|
|
1250
|
+
| `Register<typeof Model, Name?, Kind?>` | Extend the registry with a model |
|
|
1251
|
+
| Builder `.object`, `.interface`, `.union`, `.enum`, `.scalar` | Define named output types |
|
|
1252
|
+
| Builder `.queryField`, `.mutationField`, `.subscriptionField` | Define one named operation field |
|
|
1253
|
+
| Builder `.id()`, `.string()`, `.nullable`, `.list`, `.field` | Define output fields |
|
|
1254
|
+
| Builder `.connection` | Define connection fields |
|
|
1255
|
+
| Builder `.field`, `.nullable`, `.list` with `{ subscribe }` | Define subscription fields returning Effect Streams |
|
|
1256
|
+
| Builder `.id(Schema)`, `.input`, `.default` | Annotate argument/input schemas |
|
|
1257
|
+
| Builder `.withErrorPolicy` | Classify global public and internal typed failures |
|
|
1258
|
+
| Builder `.schema(definitions)` | Check completeness and build a `BuiltSchema<R, C>` |
|
|
1259
|
+
| `execute`, `subscribe` (also on the builder) | Run source-text requests as Effects or Streams |
|
|
1260
|
+
| `executor(built)` | Execute parsed documents with JSON-safe formatted results |
|
|
1261
|
+
| `ExecutionResult`, `FormattedExecutionResult` | Direct graphql-js results and formatted executor results |
|
|
1262
|
+
| `ErrorReporter` | Optional service for masked-failure telemetry |
|
|
1263
|
+
| `ID`, `DateTime`, `JSONValue` | Built-in input schemas |
|
|
1264
|
+
| `id`, `input`, `defaulted` | Package-level schema annotation helpers |
|
|
1265
|
+
| `Connection`, `Edge`, `PageInfo`, `ConnectionArgs` | Structural pagination types |
|
|
1266
|
+
| `@model-ts/graphql/Testing` | Test clients, the graphql tag, and normalized snapshots |
|
|
1267
|
+
|
|
1268
|
+
## Development
|
|
1269
|
+
|
|
1270
|
+
From the repository root, `bun run build`, `bun run typecheck`, `bun run lint`,
|
|
1271
|
+
`bun run format:check`, and `bun run test` include this package. Its tests use
|
|
1272
|
+
the in-memory DynamoDB Sandbox.
|
|
1273
|
+
|
|
1274
|
+
From `packages/graphql`:
|
|
1275
|
+
|
|
1276
|
+
| Command | Checks |
|
|
1277
|
+
| ----------------------- | ----------------------------------------------------------------- |
|
|
1278
|
+
| `bun run test` | Runtime and in-memory storage tests |
|
|
1279
|
+
| `bun run test:types` | Inferred parent, argument, context, and service types |
|
|
1280
|
+
| `bun run test:compiler` | Negative typing and registry diagnostics with native TypeScript 7 |
|
|
1281
|
+
| `bun run test:scale` | A generated 200-model schema |
|