@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1280 -2
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/Assembly.d.ts +24 -0
  5. package/dist/Assembly.d.ts.map +1 -0
  6. package/dist/Assembly.js +236 -0
  7. package/dist/Assembly.js.map +1 -0
  8. package/dist/Builder.d.ts +27 -0
  9. package/dist/Builder.d.ts.map +1 -0
  10. package/dist/Builder.js +40 -0
  11. package/dist/Builder.js.map +1 -0
  12. package/dist/Connections.d.ts +41 -0
  13. package/dist/Connections.d.ts.map +1 -0
  14. package/dist/Connections.js +243 -0
  15. package/dist/Connections.js.map +1 -0
  16. package/dist/Definitions.d.ts +139 -0
  17. package/dist/Definitions.d.ts.map +1 -0
  18. package/dist/Definitions.js +117 -0
  19. package/dist/Definitions.js.map +1 -0
  20. package/dist/Errors.d.ts +39 -0
  21. package/dist/Errors.d.ts.map +1 -0
  22. package/dist/Errors.js +103 -0
  23. package/dist/Errors.js.map +1 -0
  24. package/dist/Execution.d.ts +52 -0
  25. package/dist/Execution.d.ts.map +1 -0
  26. package/dist/Execution.js +174 -0
  27. package/dist/Execution.js.map +1 -0
  28. package/dist/FieldRuntime.d.ts +5 -0
  29. package/dist/FieldRuntime.d.ts.map +1 -0
  30. package/dist/FieldRuntime.js +27 -0
  31. package/dist/FieldRuntime.js.map +1 -0
  32. package/dist/FieldTypes.d.ts +232 -0
  33. package/dist/FieldTypes.d.ts.map +1 -0
  34. package/dist/FieldTypes.js +6 -0
  35. package/dist/FieldTypes.js.map +1 -0
  36. package/dist/Fields.d.ts +8 -0
  37. package/dist/Fields.d.ts.map +1 -0
  38. package/dist/Fields.js +72 -0
  39. package/dist/Fields.js.map +1 -0
  40. package/dist/Formatting.d.ts +25 -0
  41. package/dist/Formatting.d.ts.map +1 -0
  42. package/dist/Formatting.js +29 -0
  43. package/dist/Formatting.js.map +1 -0
  44. package/dist/GraphQL.d.ts +10 -0
  45. package/dist/GraphQL.d.ts.map +1 -0
  46. package/dist/GraphQL.js +5 -0
  47. package/dist/GraphQL.js.map +1 -0
  48. package/dist/InputTypes.d.ts +27 -0
  49. package/dist/InputTypes.d.ts.map +1 -0
  50. package/dist/InputTypes.js +203 -0
  51. package/dist/InputTypes.js.map +1 -0
  52. package/dist/Registry.d.ts +70 -0
  53. package/dist/Registry.d.ts.map +1 -0
  54. package/dist/Registry.js +3 -0
  55. package/dist/Registry.js.map +1 -0
  56. package/dist/Resolver.d.ts +30 -0
  57. package/dist/Resolver.d.ts.map +1 -0
  58. package/dist/Resolver.js +76 -0
  59. package/dist/Resolver.js.map +1 -0
  60. package/dist/Results.d.ts +20 -0
  61. package/dist/Results.d.ts.map +1 -0
  62. package/dist/Results.js +79 -0
  63. package/dist/Results.js.map +1 -0
  64. package/dist/ScalarBoundary.d.ts +9 -0
  65. package/dist/ScalarBoundary.d.ts.map +1 -0
  66. package/dist/ScalarBoundary.js +31 -0
  67. package/dist/ScalarBoundary.js.map +1 -0
  68. package/dist/Scalars.d.ts +9 -0
  69. package/dist/Scalars.d.ts.map +1 -0
  70. package/dist/Scalars.js +104 -0
  71. package/dist/Scalars.js.map +1 -0
  72. package/dist/SchemaTypes.d.ts +55 -0
  73. package/dist/SchemaTypes.d.ts.map +1 -0
  74. package/dist/SchemaTypes.js +47 -0
  75. package/dist/SchemaTypes.js.map +1 -0
  76. package/dist/Subscription.d.ts +6 -0
  77. package/dist/Subscription.d.ts.map +1 -0
  78. package/dist/Subscription.js +60 -0
  79. package/dist/Subscription.js.map +1 -0
  80. package/dist/Testing.d.ts +52 -0
  81. package/dist/Testing.d.ts.map +1 -0
  82. package/dist/Testing.js +74 -0
  83. package/dist/Testing.js.map +1 -0
  84. package/dist/index.d.ts +2 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/index.js +2 -0
  87. package/dist/index.js.map +1 -0
  88. package/dist/internal/SubscriptionRule.d.ts +8 -0
  89. package/dist/internal/SubscriptionRule.d.ts.map +1 -0
  90. package/dist/internal/SubscriptionRule.js +73 -0
  91. package/dist/internal/SubscriptionRule.js.map +1 -0
  92. package/package.json +55 -4
  93. package/src/Assembly.ts +352 -0
  94. package/src/Builder.ts +110 -0
  95. package/src/Connections.ts +374 -0
  96. package/src/Definitions.ts +465 -0
  97. package/src/Errors.ts +188 -0
  98. package/src/Execution.ts +354 -0
  99. package/src/FieldRuntime.ts +32 -0
  100. package/src/FieldTypes.ts +667 -0
  101. package/src/Fields.ts +117 -0
  102. package/src/Formatting.ts +54 -0
  103. package/src/GraphQL.ts +50 -0
  104. package/src/InputTypes.ts +266 -0
  105. package/src/Registry.ts +157 -0
  106. package/src/Resolver.ts +176 -0
  107. package/src/Results.ts +128 -0
  108. package/src/ScalarBoundary.ts +30 -0
  109. package/src/Scalars.ts +143 -0
  110. package/src/SchemaTypes.ts +90 -0
  111. package/src/Subscription.ts +93 -0
  112. package/src/Testing.ts +144 -0
  113. package/src/index.ts +1 -0
  114. package/src/internal/SubscriptionRule.ts +90 -0
package/README.md CHANGED
@@ -1,3 +1,1281 @@
1
- # Temporary Holding Version
1
+ # @model-ts/graphql
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 |