@distilled.cloud/core 1.0.0-rc.12 → 1.0.0-rc.13

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 (149) hide show
  1. package/lib/api.d.ts +4 -4
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +1 -1
  4. package/lib/api.js.map +1 -1
  5. package/lib/category.js +1 -1
  6. package/lib/category.js.map +1 -1
  7. package/lib/codegen/boolean-string-enums.d.ts +8 -8
  8. package/lib/codegen/boolean-string-enums.d.ts.map +1 -1
  9. package/lib/codegen/boolean-string-enums.js +84 -27
  10. package/lib/codegen/boolean-string-enums.js.map +1 -1
  11. package/lib/codegen/boolean-string-enums.test.js +130 -0
  12. package/lib/codegen/boolean-string-enums.test.js.map +1 -1
  13. package/lib/codegen/cli.js +2 -2
  14. package/lib/codegen/cli.js.map +1 -1
  15. package/lib/codegen/generator.d.ts +20 -3
  16. package/lib/codegen/generator.d.ts.map +1 -1
  17. package/lib/codegen/generator.js +36 -9
  18. package/lib/codegen/generator.js.map +1 -1
  19. package/lib/codegen/generator.test.d.ts +2 -0
  20. package/lib/codegen/generator.test.d.ts.map +1 -0
  21. package/lib/codegen/generator.test.js +126 -0
  22. package/lib/codegen/generator.test.js.map +1 -0
  23. package/lib/codegen/graphql-client.d.ts +7 -1
  24. package/lib/codegen/graphql-client.d.ts.map +1 -1
  25. package/lib/codegen/graphql-client.js +262 -125
  26. package/lib/codegen/graphql-client.js.map +1 -1
  27. package/lib/codegen/graphql-client.test.js +104 -236
  28. package/lib/codegen/graphql-client.test.js.map +1 -1
  29. package/lib/codegen/openapi-binary.test.d.ts +2 -0
  30. package/lib/codegen/openapi-binary.test.d.ts.map +1 -0
  31. package/lib/codegen/openapi-binary.test.js +67 -0
  32. package/lib/codegen/openapi-binary.test.js.map +1 -0
  33. package/lib/codegen/openapi.d.ts +7 -1
  34. package/lib/codegen/openapi.d.ts.map +1 -1
  35. package/lib/codegen/openapi.js +15 -4
  36. package/lib/codegen/openapi.js.map +1 -1
  37. package/lib/codegen/patches.d.ts +16 -1
  38. package/lib/codegen/patches.d.ts.map +1 -1
  39. package/lib/codegen/patches.js +70 -4
  40. package/lib/codegen/patches.js.map +1 -1
  41. package/lib/codegen/patches.test.js +48 -2
  42. package/lib/codegen/patches.test.js.map +1 -1
  43. package/lib/codegen/untagged-unions.test.d.ts +2 -0
  44. package/lib/codegen/untagged-unions.test.d.ts.map +1 -0
  45. package/lib/codegen/untagged-unions.test.js +80 -0
  46. package/lib/codegen/untagged-unions.test.js.map +1 -0
  47. package/lib/errors.d.ts +2 -1
  48. package/lib/errors.d.ts.map +1 -1
  49. package/lib/errors.js +5 -2
  50. package/lib/errors.js.map +1 -1
  51. package/lib/fixtures/xml/upstream-validator.json +397 -0
  52. package/lib/graphql.d.ts +296 -205
  53. package/lib/graphql.d.ts.map +1 -1
  54. package/lib/graphql.js +968 -631
  55. package/lib/graphql.js.map +1 -1
  56. package/lib/graphql.test.js +134 -763
  57. package/lib/graphql.test.js.map +1 -1
  58. package/lib/graphql.types.d.ts +10 -0
  59. package/lib/graphql.types.d.ts.map +1 -1
  60. package/lib/graphql.types.js +4 -44
  61. package/lib/graphql.types.js.map +1 -1
  62. package/lib/index.d.ts +27 -0
  63. package/lib/index.d.ts.map +1 -0
  64. package/lib/index.js +27 -0
  65. package/lib/index.js.map +1 -0
  66. package/lib/pagination.d.ts +2 -1
  67. package/lib/pagination.d.ts.map +1 -1
  68. package/lib/pagination.js +24 -16
  69. package/lib/pagination.js.map +1 -1
  70. package/lib/pagination.test.d.ts +2 -0
  71. package/lib/pagination.test.d.ts.map +1 -0
  72. package/lib/pagination.test.js +134 -0
  73. package/lib/pagination.test.js.map +1 -0
  74. package/lib/protocol-http.d.ts +12 -17
  75. package/lib/protocol-http.d.ts.map +1 -1
  76. package/lib/protocol-http.js +100 -45
  77. package/lib/protocol-http.js.map +1 -1
  78. package/lib/protocol-http.test.js +130 -0
  79. package/lib/protocol-http.test.js.map +1 -1
  80. package/lib/protocol-rest.d.ts +18 -1
  81. package/lib/protocol-rest.d.ts.map +1 -1
  82. package/lib/protocol-rest.js +24 -4
  83. package/lib/protocol-rest.js.map +1 -1
  84. package/lib/query.d.ts +29 -0
  85. package/lib/query.d.ts.map +1 -0
  86. package/lib/query.js +27 -0
  87. package/lib/query.js.map +1 -0
  88. package/lib/response-validation.d.ts +67 -0
  89. package/lib/response-validation.d.ts.map +1 -0
  90. package/lib/response-validation.js +76 -0
  91. package/lib/response-validation.js.map +1 -0
  92. package/lib/response-validation.test.d.ts +2 -0
  93. package/lib/response-validation.test.d.ts.map +1 -0
  94. package/lib/response-validation.test.js +67 -0
  95. package/lib/response-validation.test.js.map +1 -0
  96. package/lib/testing.d.ts +39 -0
  97. package/lib/testing.d.ts.map +1 -0
  98. package/lib/testing.js +41 -0
  99. package/lib/testing.js.map +1 -0
  100. package/lib/trait.d.ts +29 -9
  101. package/lib/trait.d.ts.map +1 -1
  102. package/lib/trait.js +8 -0
  103. package/lib/trait.js.map +1 -1
  104. package/lib/xml.d.ts +44 -0
  105. package/lib/xml.d.ts.map +1 -0
  106. package/lib/xml.js +315 -0
  107. package/lib/xml.js.map +1 -0
  108. package/lib/xml.test.d.ts +2 -0
  109. package/lib/xml.test.d.ts.map +1 -0
  110. package/lib/xml.test.js +211 -0
  111. package/lib/xml.test.js.map +1 -0
  112. package/package.json +4 -4
  113. package/src/api.ts +4 -4
  114. package/src/category.ts +1 -1
  115. package/src/codegen/boolean-string-enums.test.ts +147 -0
  116. package/src/codegen/boolean-string-enums.ts +84 -27
  117. package/src/codegen/cli.ts +2 -2
  118. package/src/codegen/generator.test.ts +151 -0
  119. package/src/codegen/generator.ts +70 -12
  120. package/src/codegen/graphql-client.test.ts +116 -283
  121. package/src/codegen/graphql-client.ts +326 -169
  122. package/src/codegen/openapi-binary.test.ts +98 -0
  123. package/src/codegen/openapi.ts +26 -4
  124. package/src/codegen/patches.test.ts +66 -1
  125. package/src/codegen/patches.ts +84 -4
  126. package/src/codegen/untagged-unions.test.ts +89 -0
  127. package/src/errors.ts +5 -2
  128. package/src/fixtures/xml/upstream-validator.json +397 -0
  129. package/src/graphql.test.ts +174 -948
  130. package/src/graphql.ts +1592 -1167
  131. package/src/graphql.types.ts +92 -163
  132. package/src/index.ts +26 -0
  133. package/src/pagination.test.ts +191 -0
  134. package/src/pagination.ts +35 -25
  135. package/src/protocol-http.test.ts +163 -0
  136. package/src/protocol-http.ts +122 -46
  137. package/src/protocol-rest.ts +62 -13
  138. package/src/query.ts +39 -0
  139. package/src/response-validation.test.ts +95 -0
  140. package/src/response-validation.ts +116 -0
  141. package/src/testing.ts +78 -0
  142. package/src/trait.ts +39 -8
  143. package/src/xml.test.ts +269 -0
  144. package/src/xml.ts +339 -0
  145. package/lib/graphql.fixture.d.ts +0 -249
  146. package/lib/graphql.fixture.d.ts.map +0 -1
  147. package/lib/graphql.fixture.js +0 -240
  148. package/lib/graphql.fixture.js.map +0 -1
  149. package/src/graphql.fixture.ts +0 -371
package/src/graphql.ts CHANGED
@@ -1,205 +1,81 @@
1
- /** GraphQL selections, typed failures and Effect execution for generated SDKs. */
1
+ /**
2
+ * GraphQL Query algebra — the runtime generated GraphQL SDKs target.
3
+ *
4
+ * ── Why this exists ────────────────────────────────────────────────────────
5
+ * A GraphQL document is a *selection*, not an RPC. Distilled used to bake a
6
+ * max-depth selection into every operation (`Railway.me({})` downloaded the
7
+ * world). This module instead treats every field as a lazy `Query<Value>`:
8
+ * reading `user.email` records that field, and nothing is posted until
9
+ * `Query.fn` evaluates the plan you returned.
10
+ *
11
+ * ── The two phases ─────────────────────────────────────────────────────────
12
+ * 1. Build (your function). `Railway.me()` / `user.email` / `Query.map` do
13
+ * not talk to the network. They construct an expression tree:
14
+ * Of | Map | Filter | FlatMap | Prop | Root
15
+ * The inner function of `Query.fn` is therefore a *builder*, not an
16
+ * Effect generator. No `yield*` inside it.
17
+ *
18
+ * 2. Evaluate (`Query.fn`). Walk the returned plan, paint every GraphQL
19
+ * field that appears onto one selection forest, POST that document
20
+ * once through {@link GqlTransport}, then interpret Map/Filter/FlatMap
21
+ * against the JSON. Effects reached via FlatMap (e.g.
22
+ * `usernameIsAvailable(name)`) run *after* the POST, with real values.
23
+ *
24
+ * ── Combinators ────────────────────────────────────────────────────────────
25
+ * - `Query.of(x)` — a literal; never selected.
26
+ * - `Query.map` on a list — callback receives `Query<Item>` so `p.name` is
27
+ * still a Query (selected). On a scalar, callback receives the value
28
+ * after extract (`stars => stars > 5`). Relay connections (`edges { node }`)
29
+ * are lists of the node type, so `projects({ first: 20 }).pipe(Query.map)`
30
+ * maps projects, not edges.
31
+ * - `Query.filter` — predicate is `Query<Item> => Query<boolean>`; the
32
+ * boolean Query is walked for fields, then run per row after the POST.
33
+ * - `Query.flatMap` — after extract, run `(value) => Effect | Query`.
34
+ * - `Query.pages` / `Query.items` — a Stream over every page / node of a
35
+ * Relay connection, one POST per page (see "Pagination").
36
+ *
37
+ * ── Types ──────────────────────────────────────────────────────────────────
38
+ * `Query<Value>` is `QueryNode<Value>` plus the GraphQL fields of `Value`,
39
+ * so `user.name` type-checks. That copy is a circular type alias
40
+ * (`Query<User>` contains `Query<Project[]>` which contains `Query<User>`
41
+ * again). TypeScript leaves the cycle lazy; there is no depth cap.
42
+ *
43
+ * ── Transport ──────────────────────────────────────────────────────────────
44
+ * {@link GqlTransport.execute} posts `{ query, variables, operationName }`.
45
+ * The Effect it returns may require Credentials / HttpClient; those leak
46
+ * into `Query.fn`'s requirements. It returns the parsed `data` and raw
47
+ * `errors`; `Query.fn` classifies the errors (see "Errors" below). Compile
48
+ * failures are {@link GqlError} defects.
49
+ *
50
+ * Generated SDKs export *roots* (`Railway.me`, `Railway.project`).
51
+ * Combinators live in `@distilled.cloud/core/query`.
52
+ */
53
+ import * as Context from "effect/Context";
2
54
  import * as Data from "effect/Data";
3
55
  import * as Effect from "effect/Effect";
4
56
  import * as Option from "effect/Option";
5
- import * as S from "effect/Schema";
6
57
  import * as Schedule from "effect/Schedule";
58
+ import * as S from "effect/Schema";
7
59
  import * as Stream from "effect/Stream";
8
- import {
9
- Kind,
10
- parseType,
11
- print,
12
- type FieldNode,
13
- type SelectionNode,
14
- type VariableDefinitionNode,
15
- } from "graphql";
16
- import type { GraphQLModel, GraphQLField } from "./codegen/graphql-client.ts";
17
-
18
- export type { GraphQLModel } from "./codegen/graphql-client.ts";
19
-
20
- /** A generated field's arguments, GraphQL output reference, and error tags. */
21
- export interface Field<Args, Ref extends string, E extends string = never> {
22
- readonly args: Args;
23
- readonly type: Ref;
24
- readonly errors: E;
25
- }
26
60
 
27
- export interface Schema {
28
- types: Record<string, Record<string, Field<any, string, string>>>;
29
- scalars: Record<string, unknown>;
30
- errors: Record<string, GraphQLIssue>;
31
- possibleTypes: Record<string, string>;
32
- query: string;
33
- mutation: string;
34
- subscription: string;
35
- globalErrors: string;
36
- }
61
+ export const QuerySymbol = Symbol.for("@distilled.cloud/graphql/Query");
62
+ const inspect = Symbol.for("nodejs.util.inspect.custom");
37
63
 
38
- type Named<T extends string> = T extends `${infer R}!`
39
- ? Named<R>
40
- : T extends `[${infer R}]`
41
- ? Named<R>
42
- : T;
43
- type Fields<C extends Schema, N extends string> = N extends keyof C["types"]
44
- ? C["types"][N]
45
- : {};
46
- type TypeOf<F> = F extends Field<any, infer T, any> ? T : never;
47
- type ArgsOf<F> = F extends Field<infer A, any, any> ? A : never;
48
- type TagsOf<F> = F extends Field<any, any, infer E> ? E : never;
49
- type Where<A> = {} extends A ? { readonly where?: A } : { readonly where: A };
50
- type Directives = { readonly $include?: boolean; readonly $skip?: boolean };
51
- type FieldSelection<C extends Schema, F> =
52
- Named<TypeOf<F>> extends keyof C["scalars"]
53
- ?
54
- | ({} extends ArgsOf<F> ? boolean : never)
55
- | (Where<ArgsOf<F>> & Directives & { readonly select?: true })
56
- :
57
- | (keyof ArgsOf<F> extends never
58
- ? Selection<C, Named<TypeOf<F>>>
59
- : never)
60
- | (Where<ArgsOf<F>> &
61
- Directives & { readonly select: Selection<C, Named<TypeOf<F>>> });
62
- type Alias<C extends Schema, N extends string> = {
63
- [K in keyof Fields<C, N>]: {
64
- readonly [P in K]: FieldSelection<C, Fields<C, N>[K]>;
65
- };
66
- }[keyof Fields<C, N>];
67
-
68
- /** Mobius-style field selection. Arguments are passed in `where`. */
69
- export type Selection<C extends Schema, N extends string> = {
70
- readonly [K in keyof Fields<C, N>]?: FieldSelection<C, Fields<C, N>[K]>;
71
- } & {
72
- readonly __typename?: boolean;
73
- readonly __alias?: Readonly<Record<string, Alias<C, N>>>;
74
- readonly __on?: N extends keyof C["possibleTypes"]
75
- ? { readonly [K in C["possibleTypes"][N]]?: Selection<C, K> }
76
- : never;
77
- };
78
- type Selected<Q> = Q extends { readonly select: infer P } ? P : Q;
79
- type Keys<Q> = {
80
- [K in keyof Q]: Q[K] extends false | undefined ? never : K;
81
- }[keyof Q];
82
- type Simplify<A> = { [K in keyof A]: A[K] };
83
- type AliasValue<C extends Schema, N extends string, Q> = {
84
- [K in Extract<Keys<Q>, keyof Fields<C, N>>]: Result<
85
- C,
86
- TypeOf<Fields<C, N>[K]>,
87
- Selected<Q[K]>
88
- >;
89
- }[Extract<Keys<Q>, keyof Fields<C, N>>];
90
- type AliasResult<C extends Schema, N extends string, Q> = Q extends {
91
- readonly __alias: infer A;
64
+ /** A plan that cannot be compiled (a programming error, raised as a defect). */
65
+ export class GqlError {
66
+ readonly _tag = "GqlError" as const;
67
+ constructor(readonly message: string) {}
92
68
  }
93
- ? Simplify<
94
- {
95
- [
96
- K in keyof A as Conditional<A[K][keyof A[K]]> extends true ? never : K
97
- ]: AliasValue<C, N, A[K]>;
98
- } & {
99
- [
100
- K in keyof A as Conditional<A[K][keyof A[K]]> extends true ? K : never
101
- ]?: AliasValue<C, N, A[K]>;
102
- }
103
- >
104
- : {};
105
- type Conditional<Q> = boolean extends Q
106
- ? true
107
- : Q extends { readonly $include: infer I }
108
- ? [I] extends [true]
109
- ? Q extends { readonly $skip: infer S }
110
- ? [S] extends [false]
111
- ? false
112
- : true
113
- : false
114
- : true
115
- : Q extends { readonly $skip: infer S }
116
- ? [S] extends [false]
117
- ? false
118
- : true
119
- : false;
120
- type ObjectFields<C extends Schema, N extends string, Q> = {
121
- [
122
- K in Extract<Keys<Q>, keyof Fields<C, N>> as Conditional<Q[K]> extends true
123
- ? never
124
- : K
125
- ]: Result<C, TypeOf<Fields<C, N>[K]>, Selected<Q[K]>>;
126
- } & {
127
- [
128
- K in Extract<Keys<Q>, keyof Fields<C, N>> as Conditional<Q[K]> extends true
129
- ? K
130
- : never
131
- ]?: Result<C, TypeOf<Fields<C, N>[K]>, Selected<Q[K]>>;
132
- };
133
- type ObjectResult<C extends Schema, N extends string, Q> = Simplify<
134
- ObjectFields<C, N, Q> &
135
- (Q extends { readonly __typename: true } ? { __typename: N } : {}) &
136
- AliasResult<C, N, Q>
137
- >;
138
- type Branch<
139
- C extends Schema,
140
- N extends string,
141
- Q,
142
- T extends string,
143
- > = T extends unknown
144
- ? Simplify<
145
- Omit<ObjectResult<C, N, Q>, "__typename"> &
146
- (Q extends { readonly __on: infer O }
147
- ? T extends keyof O
148
- ? ObjectResult<C, T, O[T]>
149
- : {}
150
- : {}) & { __typename: T }
151
- >
152
- : never;
153
- type Value<C extends Schema, T extends string, Q> = T extends `[${infer I}]`
154
- ? Array<Result<C, I, Q>>
155
- : T extends keyof C["scalars"]
156
- ? C["scalars"][T]
157
- : T extends keyof C["possibleTypes"]
158
- ? Branch<C, T, Q, C["possibleTypes"][T]>
159
- : ObjectResult<C, T, Q>;
160
- /** Selected output, preserving list and nullable wrappers. */
161
- export type Result<
162
- C extends Schema,
163
- Ref extends string,
164
- Q,
165
- > = Ref extends `${infer T}!` ? Value<C, T, Q> : Value<C, Ref, Q> | null;
166
- type SelectionTags<C extends Schema, N extends string, Q> = string extends N
167
- ? string
168
- : Q extends object
169
- ?
170
- | {
171
- [K in Extract<Keys<Q>, keyof Fields<C, N>>]:
172
- | TagsOf<Fields<C, N>[K]>
173
- | SelectionTags<
174
- C,
175
- Named<TypeOf<Fields<C, N>[K]>>,
176
- Selected<Q[K]>
177
- >;
178
- }[Extract<Keys<Q>, keyof Fields<C, N>>]
179
- | (Q extends { readonly __on: infer O }
180
- ? { [K in keyof O & string]: SelectionTags<C, K, O[K]> }[keyof O &
181
- string]
182
- : never)
183
- | (Q extends { readonly __alias: infer A }
184
- ? { [K in keyof A]: SelectionTags<C, N, A[K]> }[keyof A]
185
- : never)
186
- : never;
187
- /** Known selected errors plus provider-wide errors and an honest fallback. */
188
- export type Errors<C extends Schema, N extends string, Q> =
189
- | (SelectionTags<C, N, Q> extends infer Tags
190
- ? C["errors"][Extract<C["globalErrors"] | Tags, keyof C["errors"]>]
191
- : never)
192
- | UnknownGraphQLError;
193
- type Root<C extends Schema, K extends OperationKind> = K extends "query"
194
- ? C["query"]
195
- : C["mutation"];
196
- type OperationSelection<
197
- C extends Schema,
198
- N extends string,
199
- K extends keyof Fields<C, N>,
200
- Q,
201
- > = { [P in K]: { select: Q } };
202
69
 
70
+ // ── Errors ──────────────────────────────────────────────────────────────────
71
+ //
72
+ // Every entry of a GraphQL `errors` array becomes one typed issue. Generated
73
+ // SDKs declare tagged error classes plus the matchers that recognize them,
74
+ // scoped to the root fields that can return them (or global). `Query.fn`
75
+ // classifies each entry against the roots its path points at, so the error
76
+ // channel is the union of the errors declared by the roots the plan reads.
77
+
78
+ /** Fields every classified GraphQL error carries. */
203
79
  export const errorFields = {
204
80
  message: S.String,
205
81
  code: S.optional(S.String),
@@ -210,8 +86,11 @@ export const errorFields = {
210
86
  extensions: S.optional(S.Unknown),
211
87
  traceId: S.optional(S.String),
212
88
  status: S.optional(S.Number),
89
+ /** Server retry hint in seconds. */
213
90
  retryAfter: S.optional(S.Number),
214
91
  };
92
+
93
+ /** The shape shared by every classified GraphQL error. */
215
94
  export interface GraphQLIssue {
216
95
  readonly _tag: string;
217
96
  readonly message: string;
@@ -224,23 +103,28 @@ export interface GraphQLIssue {
224
103
  readonly extensions?: unknown;
225
104
  readonly traceId?: string;
226
105
  readonly status?: number;
227
- /** Server retry hint in seconds. */
228
106
  readonly retryAfter?: number;
229
107
  }
108
+
109
+ export type GraphQLIssueProps = Omit<GraphQLIssue, "_tag">;
110
+
111
+ /** A GraphQL error no declared matcher recognizes. */
230
112
  export class UnknownGraphQLError extends S.TaggedError<UnknownGraphQLError>()(
231
113
  "UnknownGraphQLError",
232
114
  {
233
115
  ...errorFields,
116
+ /** Root field the error path points at, when the path is known. */
234
117
  coordinate: S.optional(S.String),
235
118
  },
236
119
  ) {}
237
- /** All errors from one GraphQL execution, with the original partial response. */
120
+
121
+ /** One response carried errors with different tags; none is hidden. */
238
122
  export class GraphQLFailure<
239
123
  E extends GraphQLIssue = GraphQLIssue,
240
124
  > extends Data.TaggedError("GraphQLFailure")<{
241
125
  readonly errors: readonly [E, ...E[]];
242
126
  readonly data: unknown;
243
- readonly status: number;
127
+ readonly status?: number;
244
128
  }> {
245
129
  get message(): string {
246
130
  return this.errors
@@ -251,622 +135,1225 @@ export class GraphQLFailure<
251
135
  .join("; ");
252
136
  }
253
137
  }
138
+
139
+ /** The request never produced a GraphQL response (network, HTTP, non-JSON). */
254
140
  export class GraphQLTransportError extends Data.TaggedError(
255
141
  "GraphQLTransportError",
256
142
  )<{
257
143
  readonly message: string;
258
- readonly cause?: unknown;
259
144
  readonly status?: number;
145
+ /** Server retry hint in seconds. */
260
146
  readonly retryAfter?: number;
147
+ readonly cause?: unknown;
261
148
  }> {}
262
- export class GraphQLDecodeError extends Data.TaggedError("GraphQLDecodeError")<{
149
+
150
+ /** A paginated connection reported more pages without a new cursor. */
151
+ export class GraphQLPaginationError extends Data.TaggedError(
152
+ "GraphQLPaginationError",
153
+ )<{
263
154
  readonly message: string;
264
- readonly path?: ReadonlyArray<string | number>;
265
- readonly cause?: unknown;
155
+ readonly cursor: string | null;
266
156
  }> {}
267
- export class GraphQLRequestError extends Data.TaggedError(
268
- "GraphQLRequestError",
269
- )<{ readonly message: string }> {}
270
- export type ClientError =
271
- | GraphQLTransportError
272
- | GraphQLDecodeError
273
- | GraphQLRequestError;
274
- type IssueOf<E> = E extends GraphQLFailure<infer I> ? I : never;
275
- /** True only when every issue has an allowed tag; mixed failures are never hidden. */
276
- export const isErrorTag = (
277
- error: unknown,
278
- tags: string | readonly string[],
279
- ): boolean => {
280
- const allowed = typeof tags === "string" ? [tags] : tags;
281
- return (
282
- error instanceof GraphQLFailure &&
283
- error.errors.length > 0 &&
284
- error.errors.every((issue) => allowed.includes(issue._tag))
285
- );
286
- };
287
- /** Recover only an aggregate whose every issue matches. The original aggregate is also supplied. */
288
- export const catchTags =
289
- <const T extends readonly string[] | string, B, E2, R2>(
290
- tags: T,
291
- handler: (
292
- error: GraphQLIssue & { readonly _tag: T extends string ? T : T[number] },
293
- failure: GraphQLFailure,
294
- ) => Effect.Effect<B, E2, R2>,
295
- ) =>
296
- <A, E, R>(
297
- self: Effect.Effect<A, E, R>,
298
- ): Effect.Effect<A | B, E | E2, R | R2> =>
299
- self.pipe(
300
- Effect.catch((error): Effect.Effect<B, E | E2, R2> =>
301
- isErrorTag(error, tags)
302
- ? handler(
303
- (error as GraphQLFailure).errors[0] as any,
304
- error as GraphQLFailure,
305
- )
306
- : Effect.fail(error),
307
- ),
308
- );
309
157
 
310
- export type OperationKind = "query" | "mutation";
311
- export interface GraphQLRequest {
312
- readonly query: string;
313
- readonly variables: Record<string, unknown>;
314
- readonly operationName: string;
315
- readonly kind: OperationKind;
158
+ /** Recognizes a raw GraphQL error. Every present property must match. */
159
+ export interface ErrorMatcher {
160
+ readonly code?: string;
161
+ readonly message?: string;
162
+ readonly messageIncludes?: string;
163
+ }
164
+
165
+ /** Runtime description of one generated error class. */
166
+ export interface ErrorSpec<E extends GraphQLIssue = GraphQLIssue> {
167
+ readonly tag: E["_tag"];
168
+ readonly make: (props: GraphQLIssueProps) => E;
169
+ readonly matchers: ReadonlyArray<ErrorMatcher>;
170
+ /** Queries failing only with retryable errors are retried. */
171
+ readonly retryable: boolean;
172
+ /** Applies to every root, including errors that carry no path. */
173
+ readonly global: boolean;
316
174
  }
175
+
176
+ export const errorSpec = <E extends GraphQLIssue>(
177
+ make: new (props: GraphQLIssueProps) => E,
178
+ tag: E["_tag"],
179
+ matchers: ReadonlyArray<ErrorMatcher>,
180
+ options: { readonly retryable?: boolean; readonly global?: boolean } = {},
181
+ ): ErrorSpec<E> => ({
182
+ tag,
183
+ make: (props) => new make(props),
184
+ matchers,
185
+ retryable: options.retryable ?? false,
186
+ global: options.global ?? false,
187
+ });
188
+
189
+ /** An entry of a GraphQL response's `errors` array, as sent on the wire. */
190
+ export interface RawGraphQLError {
191
+ readonly message: string;
192
+ readonly path?: ReadonlyArray<string | number>;
193
+ readonly locations?: ReadonlyArray<{
194
+ readonly line: number;
195
+ readonly column: number;
196
+ }>;
197
+ readonly extensions?: Record<string, unknown>;
198
+ readonly [key: string]: unknown;
199
+ }
200
+
201
+ /** What a transport returns once it has a GraphQL response body. */
317
202
  export interface GraphQLResponse {
318
- readonly body: unknown;
319
- readonly status: number;
320
- readonly headers: Readonly<Record<string, string | undefined>>;
203
+ readonly data?: unknown;
204
+ readonly errors?: ReadonlyArray<RawGraphQLError>;
205
+ readonly status?: number;
206
+ readonly headers?: Readonly<Record<string, string | undefined>>;
321
207
  }
322
- export type Transport<R> = (
323
- request: GraphQLRequest,
324
- ) => Effect.Effect<
325
- GraphQLResponse,
326
- GraphQLTransportError | GraphQLDecodeError,
327
- R
328
- >;
329
- interface Position {
330
- readonly field: string;
331
- readonly key: string;
332
- readonly coordinate: string;
333
- readonly type: string;
334
- readonly errors: readonly string[];
335
- readonly children: readonly Position[];
336
- readonly branches: Readonly<Record<string, readonly Position[]>>;
337
- readonly typename?: boolean;
338
- readonly conditional: boolean;
208
+
209
+ export type ArgMeta = Record<string, string>;
210
+
211
+ export type FieldMeta = {
212
+ readonly name: string;
213
+ readonly kind: "scalar" | "object" | "list" | "connection";
214
+ readonly of?: TypeMeta;
215
+ readonly argTypes?: ArgMeta;
216
+ };
217
+
218
+ export type TypeMeta = {
219
+ readonly name: string;
220
+ readonly fields: Record<string, FieldMeta>;
221
+ };
222
+
223
+ export const scalarField = (name: string): FieldMeta => ({
224
+ name,
225
+ kind: "scalar",
226
+ });
227
+ export const objectField = (name: string, objectType: TypeMeta): FieldMeta => ({
228
+ name,
229
+ kind: "object",
230
+ of: objectType,
231
+ });
232
+ export const listField = (
233
+ name: string,
234
+ itemType: TypeMeta,
235
+ argTypes?: ArgMeta,
236
+ ): FieldMeta => ({
237
+ name,
238
+ kind: "list",
239
+ of: itemType,
240
+ argTypes,
241
+ });
242
+ /** Relay connection: GraphQL `edges { node }` presented as `Query<Item[]>`. */
243
+ export const connectionField = (
244
+ name: string,
245
+ nodeType: TypeMeta,
246
+ argTypes?: ArgMeta,
247
+ ): FieldMeta => ({
248
+ name,
249
+ kind: "connection",
250
+ of: nodeType,
251
+ argTypes,
252
+ });
253
+
254
+ export type TypeRef =
255
+ | { readonly tag: "scalar" }
256
+ | { readonly tag: "object"; readonly meta: TypeMeta }
257
+ | { readonly tag: "list"; readonly of: TypeRef };
258
+
259
+ const objectRef = (meta: TypeMeta): TypeRef => ({ tag: "object", meta });
260
+ const listRef = (itemType: TypeRef): TypeRef => ({
261
+ tag: "list",
262
+ of: itemType,
263
+ });
264
+
265
+ const fieldResult = (parent: TypeRef, field: FieldMeta): TypeRef => {
266
+ const inner = (): TypeRef => {
267
+ if (field.kind === "scalar") return { tag: "scalar" };
268
+ if (field.kind === "object") return objectRef(field.of!);
269
+ return listRef(objectRef(field.of!)); // list | connection
270
+ };
271
+ return parent.tag === "list"
272
+ ? listRef(fieldResult(parent.of, field))
273
+ : inner();
274
+ };
275
+
276
+ const unwrapList = (typeRef: TypeRef): TypeRef => {
277
+ if (typeRef.tag !== "list") throw new GqlError("expected a list Query");
278
+ return typeRef.of;
279
+ };
280
+
281
+ const fieldsOf = (typeRef: TypeRef): Record<string, FieldMeta> => {
282
+ if (typeRef.tag === "object") return typeRef.meta.fields;
283
+ if (typeRef.tag === "list") return fieldsOf(typeRef.of);
284
+ return {};
285
+ };
286
+
287
+ export interface SelNode {
288
+ field: string;
289
+ alias: string | undefined;
290
+ args: Record<string, string> | undefined;
291
+ children: Map<string, SelNode>;
292
+ isScalar: boolean;
293
+ isList: boolean;
339
294
  }
340
- export interface CompiledQuery extends GraphQLRequest {
341
- readonly rootType: string;
342
- readonly positions: readonly Position[];
295
+
296
+ export interface CompiledOperation {
297
+ readonly document: string;
298
+ readonly operationName: string;
299
+ readonly variables: Record<string, unknown>;
300
+ readonly kind: "query" | "mutation";
301
+ readonly tree: SelNode;
343
302
  }
344
- const object = (value: unknown): value is Record<string, any> =>
345
- value !== null && typeof value === "object" && !Array.isArray(value);
346
- const named = (ref: string): string => ref.replace(/[\[\]!]/g, "");
347
- const nameNode = (value: string) => ({ kind: Kind.NAME, value }) as const;
348
- const requestError = (message: string): never => {
349
- throw new GraphQLRequestError({ message });
303
+
304
+ export class GqlTransport extends Context.Service<
305
+ GqlTransport,
306
+ {
307
+ readonly execute: (
308
+ req: CompiledOperation,
309
+ ) => Effect.Effect<GraphQLResponse, GraphQLTransportError, any>;
310
+ }
311
+ >()("@distilled.cloud/graphql/Transport") {}
312
+
313
+ const log = (..._args: Array<unknown>): void => {};
314
+
315
+ /**
316
+ * GraphQL fields copied onto a Query so `user.name` type-checks.
317
+ * `Query<User>` contains `projects: Query<Project[]>`, which contains
318
+ * `owner: Query<User>` again — TypeScript leaves the cycle lazy. Fields
319
+ * inherit the errors of the root they were read from.
320
+ */
321
+ type QueryFields<Value, Error> = [NonNullable<Value>] extends [
322
+ ReadonlyArray<infer Item>,
323
+ ]
324
+ ? {
325
+ readonly [Field in keyof NonNullable<Item>]: Query<
326
+ NonNullable<Item>[Field] | Nullish<Item>,
327
+ Error
328
+ >;
329
+ } & {
330
+ (args: Record<string, unknown>): Query<Value, Error>;
331
+ }
332
+ : [NonNullable<Value>] extends [object]
333
+ ? {
334
+ readonly [Field in keyof NonNullable<Value>]: Query<
335
+ NonNullable<Value>[Field] | Nullish<Value>,
336
+ Error
337
+ >;
338
+ }
339
+ : {};
340
+
341
+ /** A field read through a `null` parent is `null`. */
342
+ type Nullish<Value> = Extract<Value, null | undefined>;
343
+
344
+ /**
345
+ * A lazy GraphQL selection. `Value` is the plain data after `Query.fn` runs;
346
+ * `Error` is the union of typed errors the roots it reads can return.
347
+ */
348
+ export type Query<Value, Error = never> = QueryNode<Value, Error> &
349
+ QueryFields<Value, Error>;
350
+
351
+ /** Strip Query/Effect wrappers from a returned plan down to plain data. */
352
+ export type UnwrapPlan<Plan> =
353
+ Plan extends QueryNode<infer Value, any>
354
+ ? Value
355
+ : Plan extends Effect.Effect<infer Success, any, any>
356
+ ? Success
357
+ : Plan extends ReadonlyArray<infer Element>
358
+ ? ReadonlyArray<UnwrapPlan<Element>>
359
+ : Plan extends object
360
+ ? { readonly [Key in keyof Plan]: UnwrapPlan<Plan[Key]> }
361
+ : Plan;
362
+
363
+ /** Typed errors of every Query and Effect in a returned plan. */
364
+ export type PlanError<Plan> =
365
+ Plan extends QueryNode<any, infer Error>
366
+ ? Error
367
+ : Plan extends Effect.Effect<any, infer Error, any>
368
+ ? Error
369
+ : Plan extends ReadonlyArray<infer Element>
370
+ ? PlanError<Element>
371
+ : Plan extends object
372
+ ? { readonly [Key in keyof Plan]: PlanError<Plan[Key]> }[keyof Plan]
373
+ : never;
374
+
375
+ /** Failures of evaluating a plan whose roots declare `Error`. */
376
+ export type QueryError<Error> =
377
+ | Error
378
+ | GraphQLFailure<Extract<Error, GraphQLIssue> | UnknownGraphQLError>
379
+ | UnknownGraphQLError
380
+ | GraphQLTransportError;
381
+
382
+ type Expr =
383
+ | RootExpr
384
+ | PropExpr
385
+ | ItemExpr
386
+ | MapValueExpr
387
+ | MapItemsExpr
388
+ | FilterExpr
389
+ | FlatMapExpr
390
+ | LiteralExpr;
391
+
392
+ type RootExpr = {
393
+ readonly _tag: "Root";
394
+ readonly op: "query" | "mutation";
395
+ readonly field: string;
396
+ readonly args: Record<string, unknown> | undefined;
397
+ readonly argTypes: ArgMeta | undefined;
398
+ readonly type: TypeRef;
399
+ /** Relay connection: paint `edges { node }`, extract an array of nodes. */
400
+ readonly connection?: boolean;
401
+ /** Errors this root field can return, including global errors. */
402
+ readonly errors: ReadonlyArray<ErrorSpec>;
403
+ };
404
+
405
+ type PropExpr = {
406
+ readonly _tag: "Prop";
407
+ readonly parent: Expr;
408
+ readonly field: FieldMeta;
409
+ readonly type: TypeRef;
410
+ readonly args?: Record<string, unknown>;
350
411
  };
351
- const assertName = (name: string) => {
352
- if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(name))
353
- requestError(`Invalid GraphQL name ${name}`);
412
+
413
+ type ItemExpr = {
414
+ readonly _tag: "Item";
415
+ readonly list: Expr;
416
+ readonly type: TypeRef;
354
417
  };
355
418
 
356
- /** Validate and encode inputs recursively. Undefined omits; null stays null. */
357
- const encodeInput = (
358
- model: GraphQLModel,
359
- ref: string,
360
- value: unknown,
361
- at: string,
362
- ): unknown => {
363
- if (value === undefined) return undefined;
364
- if (value === null) {
365
- if (ref.endsWith("!")) requestError(`${at} must not be null`);
366
- return null;
419
+ type MapValueExpr = {
420
+ readonly _tag: "MapValue";
421
+ readonly parent: Expr;
422
+ readonly mapFn: (value: unknown) => unknown;
423
+ readonly type: TypeRef;
424
+ };
425
+
426
+ type MapItemsExpr = {
427
+ readonly _tag: "MapItems";
428
+ readonly parent: Expr;
429
+ readonly mapped: unknown;
430
+ /** Map one object (`null` stays `null`) instead of every list item. */
431
+ readonly single?: boolean;
432
+ readonly type: TypeRef;
433
+ };
434
+
435
+ type FilterExpr = {
436
+ readonly _tag: "Filter";
437
+ readonly parent: Expr;
438
+ readonly predicate: Expr;
439
+ readonly type: TypeRef;
440
+ };
441
+
442
+ type FlatMapExpr = {
443
+ readonly _tag: "FlatMap";
444
+ readonly parent: Expr;
445
+ readonly flatMapFn: (value: unknown) => unknown;
446
+ readonly type: TypeRef;
447
+ };
448
+
449
+ type LiteralExpr = {
450
+ readonly _tag: "Literal";
451
+ readonly value: unknown;
452
+ readonly type: TypeRef;
453
+ };
454
+
455
+ export class QueryNode<out Value = unknown, out Error = never> {
456
+ readonly [QuerySymbol] = QuerySymbol;
457
+ declare readonly valueType: Value;
458
+ declare readonly errorType: Error;
459
+ constructor(readonly expr: Expr) {
460
+ return proxy(this) as QueryNode<Value, Error>;
367
461
  }
368
- const base = ref.endsWith("!") ? ref.slice(0, -1) : ref;
369
- if (base.startsWith("[")) {
370
- if (!Array.isArray(value)) requestError(`${at} must be an array`);
371
- return (value as unknown[]).map((v, i) => {
372
- if (v === undefined) requestError(`${at}[${i}] must not be undefined`);
373
- return encodeInput(model, base.slice(1, -1), v, `${at}[${i}]`);
374
- });
462
+ pipe<Self>(this: Self): Self;
463
+ pipe<Self, Out1>(this: Self, step1: (_: Self) => Out1): Out1;
464
+ pipe<Self, Out1, Out2>(
465
+ this: Self,
466
+ step1: (_: Self) => Out1,
467
+ step2: (_: Out1) => Out2,
468
+ ): Out2;
469
+ pipe<Self, Out1, Out2, Out3>(
470
+ this: Self,
471
+ step1: (_: Self) => Out1,
472
+ step2: (_: Out1) => Out2,
473
+ step3: (_: Out2) => Out3,
474
+ ): Out3;
475
+ pipe<Self, Out1, Out2, Out3, Out4>(
476
+ this: Self,
477
+ step1: (_: Self) => Out1,
478
+ step2: (_: Out1) => Out2,
479
+ step3: (_: Out2) => Out3,
480
+ step4: (_: Out3) => Out4,
481
+ ): Out4;
482
+ pipe<Self, Out1, Out2, Out3, Out4, Out5>(
483
+ this: Self,
484
+ step1: (_: Self) => Out1,
485
+ step2: (_: Out1) => Out2,
486
+ step3: (_: Out2) => Out3,
487
+ step4: (_: Out3) => Out4,
488
+ step5: (_: Out4) => Out5,
489
+ ): Out5;
490
+ pipe<Self, Out1, Out2, Out3, Out4, Out5, Out6>(
491
+ this: Self,
492
+ step1: (_: Self) => Out1,
493
+ step2: (_: Out1) => Out2,
494
+ step3: (_: Out2) => Out3,
495
+ step4: (_: Out3) => Out4,
496
+ step5: (_: Out4) => Out5,
497
+ step6: (_: Out5) => Out6,
498
+ ): Out6;
499
+ pipe<Self, Out1, Out2, Out3, Out4, Out5, Out6, Out7>(
500
+ this: Self,
501
+ step1: (_: Self) => Out1,
502
+ step2: (_: Out1) => Out2,
503
+ step3: (_: Out2) => Out3,
504
+ step4: (_: Out3) => Out4,
505
+ step5: (_: Out4) => Out5,
506
+ step6: (_: Out5) => Out6,
507
+ step7: (_: Out6) => Out7,
508
+ ): Out7;
509
+ pipe<Self, Out1, Out2, Out3, Out4, Out5, Out6, Out7, Out8>(
510
+ this: Self,
511
+ step1: (_: Self) => Out1,
512
+ step2: (_: Out1) => Out2,
513
+ step3: (_: Out2) => Out3,
514
+ step4: (_: Out3) => Out4,
515
+ step5: (_: Out4) => Out5,
516
+ step6: (_: Out5) => Out6,
517
+ step7: (_: Out6) => Out7,
518
+ step8: (_: Out7) => Out8,
519
+ ): Out8;
520
+ pipe(...steps: Array<(_: any) => any>): unknown {
521
+ return steps.reduce((current, step) => step(current), this as never);
375
522
  }
376
- const type = model.types[base];
377
- if (!type) requestError(`${at}: unknown input type ${base}`);
378
- if (type.kind === "INPUT_OBJECT") {
379
- if (!object(value)) requestError(`${at} must be an input object`);
380
- const input = value as Record<string, unknown>;
381
- const result: Record<string, unknown> = {};
382
- for (const key of Object.keys(input))
383
- if (!type.inputFields?.[key])
384
- requestError(`${at}: unknown input field ${key}`);
385
- for (const [key, field] of Object.entries(type.inputFields ?? {})) {
386
- if (input[key] === undefined) {
387
- if (field.type.endsWith("!") && field.defaultValue === undefined)
388
- requestError(`${at}.${key} is required`);
389
- } else
390
- result[key] = encodeInput(
391
- model,
392
- field.type,
393
- input[key],
394
- `${at}.${key}`,
395
- );
396
- }
397
- return result;
523
+ [inspect](): string {
524
+ return printExpr(this.expr);
398
525
  }
399
- if (type.kind === "ENUM") {
400
- if (typeof value !== "string" || !type.enumValues?.includes(value))
401
- requestError(`${at}: invalid ${base}`);
402
- } else if (type.scalar === "string" || base === "String" || base === "ID") {
403
- if (typeof value !== "string") requestError(`${at} must be a string`);
404
- } else if (type.scalar === "number" || base === "Int" || base === "Float") {
405
- if (typeof value !== "number" || !Number.isFinite(value))
406
- requestError(`${at} must be a finite number`);
407
- if (
408
- base === "Int" &&
409
- (!Number.isInteger(value) ||
410
- (value as number) < -2147483648 ||
411
- (value as number) > 2147483647)
412
- )
413
- requestError(`${at} must be a GraphQL Int`);
414
- } else if (type.scalar === "boolean" || base === "Boolean") {
415
- if (typeof value !== "boolean") requestError(`${at} must be a boolean`);
526
+ toString(): string {
527
+ return this[inspect]();
416
528
  }
417
- if (
418
- type.scalar === "string | number" &&
419
- typeof value !== "string" &&
420
- typeof value !== "number"
421
- )
422
- requestError(`${at} must be a string or number`);
423
- if (
424
- base === "BigInt" &&
425
- ((typeof value === "number" && !Number.isSafeInteger(value)) ||
426
- (typeof value === "string" && !/^-?\d+$/.test(value)))
427
- )
428
- requestError(`${at} must be a safe integer or an integer string`);
429
- // JSON custom scalars must still be transportable JSON; preserve scalar contents.
430
- try {
431
- return JSON.parse(JSON.stringify(value));
432
- } catch {
433
- return requestError(`${at} is not a JSON-encodable scalar`);
529
+ }
530
+
531
+ export const isQuery = (value: unknown): value is Query<unknown> =>
532
+ (typeof value === "object" || typeof value === "function") &&
533
+ value !== null &&
534
+ QuerySymbol in value;
535
+
536
+ const make = <Value, Error = never>(expr: Expr): Query<Value, Error> =>
537
+ new QueryNode<Value, Error>(expr) as unknown as Query<Value, Error>;
538
+
539
+ const itemQuery = (list: QueryNode): QueryNode =>
540
+ new QueryNode({
541
+ _tag: "Item",
542
+ list: list.expr,
543
+ type: unwrapList(list.expr.type),
544
+ });
545
+
546
+ function proxy(self: QueryNode<unknown, unknown>): Query<unknown> {
547
+ const callable = Object.assign(function (bag?: Record<string, unknown>) {
548
+ return bag == null ? self : applyBag(self, bag);
549
+ }, self);
550
+ return new Proxy(callable, {
551
+ has: (_, prop) =>
552
+ prop === QuerySymbol ||
553
+ prop === inspect ||
554
+ prop in self ||
555
+ (typeof prop === "string" && prop in fieldsOf(self.expr.type)),
556
+ get: (_target, prop) => {
557
+ if (prop === QuerySymbol) return QuerySymbol;
558
+ if (prop === inspect) return self[inspect].bind(self);
559
+ if (prop === "pipe") return self.pipe.bind(self);
560
+ if (typeof prop === "string" && prop in self) {
561
+ const member = (self as unknown as Record<string, unknown>)[prop];
562
+ return typeof member === "function" ? member.bind(self) : member;
563
+ }
564
+ if (typeof prop !== "string") return undefined;
565
+ const field = fieldsOf(self.expr.type)[prop];
566
+ if (!field) return undefined;
567
+ return make({
568
+ _tag: "Prop",
569
+ parent: self.expr,
570
+ field,
571
+ type: fieldResult(self.expr.type, field),
572
+ });
573
+ },
574
+ apply: (_target, _thisArg, args) => applyBag(self, args[0] ?? {}),
575
+ }) as unknown as Query<unknown>;
576
+ }
577
+
578
+ const applyBag = <Value, Error>(
579
+ self: QueryNode<Value, Error>,
580
+ bag: Record<string, unknown>,
581
+ ): Query<Value, Error> => {
582
+ const argTypes =
583
+ self.expr._tag === "Prop"
584
+ ? self.expr.field.argTypes
585
+ : self.expr._tag === "Root"
586
+ ? self.expr.argTypes
587
+ : undefined;
588
+ const args: Record<string, unknown> = {};
589
+ for (const [argName, argValue] of Object.entries(bag)) {
590
+ if (argTypes && argName in argTypes) args[argName] = argValue;
591
+ }
592
+ if (self.expr._tag === "Prop") {
593
+ return make<Value, Error>({
594
+ ...self.expr,
595
+ args: { ...self.expr.args, ...args },
596
+ });
434
597
  }
598
+ if (self.expr._tag === "Root") {
599
+ return make<Value, Error>({
600
+ ...self.expr,
601
+ args: { ...self.expr.args, ...args },
602
+ });
603
+ }
604
+ return self as unknown as Query<Value, Error>;
435
605
  };
436
606
 
437
- /** Compile one selection to a GraphQL AST, variables, and an error/decoder path map. */
438
- export const compile = (
439
- model: GraphQLModel,
440
- kind: OperationKind,
441
- selection: unknown,
442
- operationName = "Distilled",
443
- ): CompiledQuery => {
444
- const rootType = kind === "query" ? model.queryType : model.mutationType;
445
- if (!rootType) return requestError(`Schema has no ${kind} root`);
446
- assertName(operationName);
447
- const variables: Record<string, unknown> = {};
448
- const definitions: VariableDefinitionNode[] = [];
449
- const variable = (ref: string, value: unknown) => {
450
- const id = `v${definitions.length}`;
451
- variables[id] = value;
452
- definitions.push({
453
- kind: Kind.VARIABLE_DEFINITION,
454
- variable: { kind: Kind.VARIABLE, name: nameNode(id) },
455
- type: parseType(ref),
607
+ export const root = <Value, Error = never>(
608
+ op: "query" | "mutation",
609
+ field: string,
610
+ meta: TypeMeta,
611
+ args?: Record<string, unknown>,
612
+ argTypes?: ArgMeta,
613
+ errors: ReadonlyArray<ErrorSpec> = [],
614
+ ): Query<Value, Error> =>
615
+ make({
616
+ _tag: "Root",
617
+ op,
618
+ field,
619
+ args,
620
+ argTypes,
621
+ type: objectRef(meta),
622
+ errors,
623
+ });
624
+
625
+ export const rootList = <Item, Error = never>(
626
+ op: "query" | "mutation",
627
+ field: string,
628
+ meta: TypeMeta,
629
+ args?: Record<string, unknown>,
630
+ argTypes?: ArgMeta,
631
+ errors: ReadonlyArray<ErrorSpec> = [],
632
+ ): Query<ReadonlyArray<Item>, Error> =>
633
+ make({
634
+ _tag: "Root",
635
+ op,
636
+ field,
637
+ args,
638
+ argTypes,
639
+ type: listRef(objectRef(meta)),
640
+ errors,
641
+ });
642
+
643
+ /** Root Relay connection: typed as `Query<Item[]>`, document uses `edges { node }`. */
644
+ export const rootConnection = <Item, Error = never>(
645
+ op: "query" | "mutation",
646
+ field: string,
647
+ meta: TypeMeta,
648
+ args?: Record<string, unknown>,
649
+ argTypes?: ArgMeta,
650
+ errors: ReadonlyArray<ErrorSpec> = [],
651
+ ): Query<ReadonlyArray<Item>, Error> =>
652
+ make({
653
+ _tag: "Root",
654
+ op,
655
+ field,
656
+ args,
657
+ argTypes,
658
+ type: listRef(objectRef(meta)),
659
+ connection: true,
660
+ errors,
661
+ });
662
+
663
+ /** Root field whose GraphQL type is a scalar, enum, or list of those. */
664
+ export const rootLeaf = <Value, Error = never>(
665
+ op: "query" | "mutation",
666
+ field: string,
667
+ list: boolean,
668
+ args?: Record<string, unknown>,
669
+ argTypes?: ArgMeta,
670
+ errors: ReadonlyArray<ErrorSpec> = [],
671
+ ): Query<Value, Error> =>
672
+ make({
673
+ _tag: "Root",
674
+ op,
675
+ field,
676
+ args,
677
+ argTypes,
678
+ type: list ? listRef({ tag: "scalar" }) : { tag: "scalar" },
679
+ errors,
680
+ });
681
+
682
+ const printExpr = (expr: Expr): string => {
683
+ switch (expr._tag) {
684
+ case "Root":
685
+ return expr.args
686
+ ? `${expr.field}(${JSON.stringify(expr.args)})`
687
+ : expr.field;
688
+ case "Prop":
689
+ return `${printExpr(expr.parent)}.${expr.field.name}`;
690
+ case "Item":
691
+ return "$item";
692
+ case "MapValue":
693
+ return `map(${printExpr(expr.parent)})`;
694
+ case "MapItems":
695
+ return `mapItems(${printExpr(expr.parent)})`;
696
+ case "Filter":
697
+ return `filter(${printExpr(expr.parent)})`;
698
+ case "FlatMap":
699
+ return `flatMap(${printExpr(expr.parent)})`;
700
+ case "Literal":
701
+ return `of(${String(expr.value)})`;
702
+ }
703
+ };
704
+
705
+ const emptySel = (field: string): SelNode => ({
706
+ field,
707
+ alias: undefined,
708
+ args: undefined,
709
+ children: new Map(),
710
+ isScalar: false,
711
+ isList: false,
712
+ });
713
+
714
+ const stableStringify = (value: unknown): string => {
715
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
716
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
717
+ const keys = Object.keys(value as object).sort();
718
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`).join(",")}}`;
719
+ };
720
+
721
+ type Forest = {
722
+ kind: "query" | "mutation" | undefined;
723
+ roots: SelNode;
724
+ vars: Map<string, { type: string; value: unknown }>;
725
+ varI: number;
726
+ aliasI: number;
727
+ rootAlias: WeakMap<object, string>;
728
+ /** Response key of each root → the root field and its declared errors. */
729
+ rootErrors: Map<string, RootErrors>;
730
+ };
731
+
732
+ interface RootErrors {
733
+ readonly field: string;
734
+ readonly errors: ReadonlyArray<ErrorSpec>;
735
+ }
736
+
737
+ const bindArgs = (
738
+ forest: Forest,
739
+ args: Record<string, unknown> | undefined,
740
+ argTypes: ArgMeta | undefined,
741
+ ): Record<string, string> | undefined => {
742
+ if (args === undefined) return undefined;
743
+ const bound: Record<string, string> = {};
744
+ let hasArgs = false;
745
+ for (const [name, value] of Object.entries(args)) {
746
+ if (value === undefined) continue;
747
+ forest.vars.set(`v${forest.varI}`, {
748
+ type: argTypes?.[name] ?? "String",
749
+ value,
456
750
  });
457
- return { kind: Kind.VARIABLE, name: nameNode(id) } as const;
751
+ bound[name] = `v${forest.varI++}`;
752
+ hasArgs = true;
753
+ }
754
+ return hasArgs ? bound : undefined;
755
+ };
756
+
757
+ const ensureChild = (
758
+ forest: Forest,
759
+ parent: SelNode,
760
+ field: string,
761
+ options: {
762
+ args?: Record<string, unknown>;
763
+ argTypes?: ArgMeta;
764
+ isScalar?: boolean;
765
+ isList?: boolean;
766
+ },
767
+ ): SelNode => {
768
+ const key =
769
+ options.args === undefined
770
+ ? field
771
+ : `${field}:${stableStringify(options.args)}`;
772
+ const existing = parent.children.get(key);
773
+ if (existing) return existing;
774
+ const taken = new Set(
775
+ [...parent.children.values()].map((c) => c.alias ?? c.field),
776
+ );
777
+ let alias: string | undefined;
778
+ if (taken.has(field)) {
779
+ do {
780
+ alias = `${field}_${forest.aliasI++}`;
781
+ } while (taken.has(alias));
782
+ }
783
+ const sel: SelNode = {
784
+ field,
785
+ alias,
786
+ args: bindArgs(forest, options.args, options.argTypes),
787
+ children: new Map(),
788
+ isScalar: options.isScalar ?? false,
789
+ isList: options.isList ?? false,
458
790
  };
459
- const walk = (
460
- parent: string,
461
- input: unknown,
462
- ): { nodes: SelectionNode[]; positions: Position[] } => {
463
- if (!object(input))
464
- return requestError(`${parent} requires an object selection`);
465
- const type = model.types[parent];
466
- if (!type) return requestError(`Unknown GraphQL type ${parent}`);
467
- const nodes: SelectionNode[] = [];
468
- const positions: Position[] = [];
469
- const addField = (fieldName: string, selected: unknown, alias?: string) => {
470
- if (selected === false || selected === undefined) return;
471
- assertName(fieldName);
472
- if (alias) assertName(alias);
473
- const key = alias ?? fieldName;
474
- if (positions.some((p) => p.key === key))
475
- return requestError(
476
- `Duplicate response key ${parent}.${key}; use distinct aliases`,
791
+ parent.children.set(key, sel);
792
+ return sel;
793
+ };
794
+
795
+ type Path = Array<string | "*">;
796
+
797
+ const paintConnection = (forest: Forest, connection: SelNode): Path => {
798
+ const edges = ensureChild(forest, connection, "edges", { isList: true });
799
+ ensureChild(forest, edges, "node", {});
800
+ return ["edges", "node"];
801
+ };
802
+
803
+ const paintAbs = (forest: Forest, expr: Expr): Path => {
804
+ switch (expr._tag) {
805
+ case "Root": {
806
+ if (forest.kind !== undefined && forest.kind !== expr.op) {
807
+ throw new GqlError(
808
+ `cannot mix ${forest.kind} and ${expr.op} in one document`,
477
809
  );
478
- const field: GraphQLField | undefined =
479
- fieldName === "__typename"
480
- ? { type: "String!", args: {}, errors: [] }
481
- : type.fields?.[fieldName];
482
- if (!field) return requestError(`Unknown field ${parent}.${fieldName}`);
483
- const wrapper =
484
- object(selected) &&
485
- ("where" in selected ||
486
- "select" in selected ||
487
- "$include" in selected ||
488
- "$skip" in selected)
489
- ? selected
490
- : undefined;
491
- const args = wrapper?.where ?? {};
492
- if (!object(args))
493
- return requestError(`${parent}.${fieldName}.where must be an object`);
494
- for (const arg of Object.keys(args))
495
- if (!field.args[arg])
496
- return requestError(
497
- `Unknown argument ${parent}.${fieldName}(${arg})`,
498
- );
499
- const arguments_: NonNullable<FieldNode["arguments"]>[number][] = [];
500
- for (const [arg, definition] of Object.entries(field.args)) {
501
- if (args[arg] === undefined) {
502
- if (
503
- definition.type.endsWith("!") &&
504
- definition.defaultValue === undefined
505
- )
506
- return requestError(`${parent}.${fieldName}(${arg}) is required`);
507
- } else
508
- arguments_.push({
509
- kind: Kind.ARGUMENT,
510
- name: nameNode(arg),
511
- value: variable(
512
- definition.type,
513
- encodeInput(
514
- model,
515
- definition.type,
516
- args[arg],
517
- `${parent}.${fieldName}(${arg})`,
518
- ),
519
- ),
520
- });
521
810
  }
522
- const directives: NonNullable<FieldNode["directives"]>[number][] = [];
523
- for (const directive of ["include", "skip"] as const) {
524
- const value = wrapper?.[`$${directive}`];
525
- if (value !== undefined) {
526
- if (typeof value !== "boolean")
527
- return requestError(`$${directive} must be a boolean`);
528
- directives.push({
529
- kind: Kind.DIRECTIVE,
530
- name: nameNode(directive),
531
- arguments: [
532
- {
533
- kind: Kind.ARGUMENT,
534
- name: nameNode("if"),
535
- value: variable("Boolean!", value),
536
- },
537
- ],
538
- });
539
- }
540
- }
541
- const outputType = model.types[named(field.type)];
542
- const leaf =
543
- fieldName === "__typename" ||
544
- outputType?.kind === "SCALAR" ||
545
- outputType?.kind === "ENUM";
546
- const sub = wrapper ? wrapper.select : selected;
547
- let child: ReturnType<typeof walk> = { nodes: [], positions: [] };
548
- if (leaf) {
549
- if (sub !== true && sub !== undefined)
550
- return requestError(
551
- `${parent}.${fieldName} is a leaf and takes no selection`,
552
- );
553
- } else child = walk(named(field.type), sub);
554
- nodes.push({
555
- kind: Kind.FIELD,
556
- name: nameNode(fieldName),
557
- ...(alias ? { alias: nameNode(alias) } : {}),
558
- arguments: arguments_,
559
- directives,
560
- ...(!leaf
561
- ? {
562
- selectionSet: {
563
- kind: Kind.SELECTION_SET,
564
- selections: child.nodes,
565
- },
566
- }
567
- : {}),
811
+ forest.kind = expr.op;
812
+ const node = ensureChild(forest, forest.roots, expr.field, {
813
+ args: expr.args,
814
+ argTypes: expr.argTypes,
815
+ // Leaf roots (`projectDelete: Boolean!`) take no sub-selection.
816
+ isScalar:
817
+ expr.type.tag === "scalar" ||
818
+ (expr.type.tag === "list" && expr.type.of.tag === "scalar"),
819
+ isList: expr.type.tag === "list" && !expr.connection,
568
820
  });
569
- const branches: Record<string, readonly Position[]> = {};
570
- // Branch metadata is attached to sentinel positions by walk and removed from regular children.
571
- for (const p of child.positions)
572
- if (p.field === "__fragment") branches[p.type] = p.children;
573
- positions.push({
574
- field: fieldName,
575
- key,
576
- coordinate: `${parent}.${fieldName}`,
577
- type: field.type,
578
- errors: field.errors,
579
- children: child.positions.filter((p) => p.field !== "__fragment"),
580
- branches,
581
- typename: fieldName === "__typename",
582
- conditional: wrapper?.$include === false || wrapper?.$skip === true,
821
+ const key = node.alias ?? node.field;
822
+ forest.rootAlias.set(expr, key);
823
+ forest.rootErrors.set(key, { field: expr.field, errors: expr.errors });
824
+ return expr.connection ? [key, ...paintConnection(forest, node)] : [key];
825
+ }
826
+ case "Prop": {
827
+ const parentPath = paintAbs(forest, expr.parent);
828
+ const parentSel = selAt(forest.roots, parentPath);
829
+ const node = ensureChild(forest, parentSel, expr.field.name, {
830
+ args: expr.args,
831
+ argTypes: expr.field.argTypes,
832
+ isScalar: expr.field.kind === "scalar",
833
+ isList: expr.field.kind === "list",
583
834
  });
584
- };
585
- for (const [key, value] of Object.entries(input)) {
586
- if (key === "__alias") {
587
- if (!object(value))
588
- return requestError(`${parent}.__alias must be an object`);
589
- for (const [alias, fields] of Object.entries(value)) {
590
- if (!object(fields) || Object.keys(fields).length !== 1)
591
- return requestError(`Alias ${alias} must select exactly one field`);
592
- const [field, selected] = Object.entries(fields)[0]!;
593
- addField(field, selected, alias);
594
- }
595
- } else if (key === "__on") {
596
- if (!object(value))
597
- return requestError(`${parent}.__on must be an object`);
598
- for (const [branch, fields] of Object.entries(value)) {
599
- if (!type.possibleTypes?.includes(branch))
600
- return requestError(
601
- `${branch} is not a possible type of ${parent}`,
602
- );
603
- const children = walk(branch, fields);
604
- nodes.push({
605
- kind: Kind.INLINE_FRAGMENT,
606
- typeCondition: { kind: Kind.NAMED_TYPE, name: nameNode(branch) },
607
- selectionSet: {
608
- kind: Kind.SELECTION_SET,
609
- selections: children.nodes,
610
- },
611
- });
612
- positions.push({
613
- field: "__fragment",
614
- key: "",
615
- coordinate: parent,
616
- type: branch,
617
- errors: [],
618
- children: children.positions,
619
- branches: {},
620
- conditional: false,
621
- });
622
- }
623
- } else addField(key, value);
835
+ const path = [...parentPath, node.alias ?? node.field];
836
+ return expr.field.kind === "connection"
837
+ ? [...path, ...paintConnection(forest, node)]
838
+ : path;
624
839
  }
625
- if (type.possibleTypes?.length && !positions.some((p) => p.typename))
626
- addField("__typename", true);
627
- if (!nodes.length) return requestError(`${parent} has an empty selection`);
628
- return { nodes, positions };
629
- };
630
- const selected = walk(rootType, selection);
631
- const query = print({
632
- kind: Kind.DOCUMENT,
633
- definitions: [
634
- {
635
- kind: Kind.OPERATION_DEFINITION,
636
- operation: kind as any,
637
- name: nameNode(operationName),
638
- variableDefinitions: definitions,
639
- selectionSet: { kind: Kind.SELECTION_SET, selections: selected.nodes },
640
- },
641
- ],
642
- });
840
+ default:
841
+ throw new GqlError(`paintAbs: ${expr._tag}`);
842
+ }
843
+ };
844
+
845
+ const selAt = (roots: SelNode, path: Path): SelNode => {
846
+ let current = roots;
847
+ for (const segment of path) {
848
+ if (segment === "*") continue;
849
+ const child = [...current.children.values()].find(
850
+ (candidate) => (candidate.alias ?? candidate.field) === segment,
851
+ );
852
+ if (!child) {
853
+ throw new GqlError(`internal: missing ${String(segment)}`);
854
+ }
855
+ current = child;
856
+ }
857
+ return current;
858
+ };
859
+
860
+ const connectionNodeSel = (connection: SelNode): SelNode => {
861
+ const edges = [...connection.children.values()].find(
862
+ (c) => c.field === "edges",
863
+ );
864
+ const node = edges
865
+ ? [...edges.children.values()].find((c) => c.field === "node")
866
+ : undefined;
867
+ if (!node) throw new GqlError("internal: connection missing edges.node");
868
+ return node;
869
+ };
870
+
871
+ const relSel = (expr: Expr, listSel: SelNode): SelNode => {
872
+ switch (expr._tag) {
873
+ case "Item":
874
+ return listSel;
875
+ case "Prop": {
876
+ const parent = relSel(expr.parent, listSel);
877
+ const child = [...parent.children.values()].find(
878
+ (c) => c.field === expr.field.name,
879
+ );
880
+ if (!child) {
881
+ throw new GqlError(`missing ${expr.field.name} in list selection`);
882
+ }
883
+ return expr.field.kind === "connection"
884
+ ? connectionNodeSel(child)
885
+ : child;
886
+ }
887
+ default:
888
+ return listSel;
889
+ }
890
+ };
891
+
892
+ const paintRel = (forest: Forest, expr: Expr, listSel: SelNode): void => {
893
+ switch (expr._tag) {
894
+ case "Item":
895
+ case "Literal":
896
+ return;
897
+ case "Prop": {
898
+ paintRel(forest, expr.parent, listSel);
899
+ const child = ensureChild(
900
+ forest,
901
+ relSel(expr.parent, listSel),
902
+ expr.field.name,
903
+ {
904
+ args: expr.args,
905
+ argTypes: expr.field.argTypes,
906
+ isScalar: expr.field.kind === "scalar",
907
+ isList: expr.field.kind === "list",
908
+ },
909
+ );
910
+ if (expr.field.kind === "connection") paintConnection(forest, child);
911
+ return;
912
+ }
913
+ case "MapValue":
914
+ case "FlatMap":
915
+ paintRel(forest, expr.parent, listSel);
916
+ return;
917
+ case "MapItems":
918
+ paintRel(forest, expr.parent, listSel);
919
+ visitRel(expr.mapped, forest, relSel(expr.parent, listSel));
920
+ return;
921
+ case "Filter":
922
+ paintRel(forest, expr.parent, listSel);
923
+ paintRel(forest, expr.predicate, relSel(expr.parent, listSel));
924
+ return;
925
+ case "Root":
926
+ throw new GqlError("root inside list item");
927
+ }
928
+ };
929
+
930
+ const visitExpr = (forest: Forest, expr: Expr): void => {
931
+ switch (expr._tag) {
932
+ case "Root":
933
+ case "Prop":
934
+ paintAbs(forest, expr);
935
+ return;
936
+ case "Item":
937
+ return;
938
+ case "Literal":
939
+ return;
940
+ case "MapValue":
941
+ case "FlatMap":
942
+ visitExpr(forest, expr.parent);
943
+ return;
944
+ case "MapItems": {
945
+ visitExpr(forest, expr.parent);
946
+ const path = paintAbs(forest, graphqlBase(expr.parent));
947
+ visitRel(expr.mapped, forest, selAt(forest.roots, path));
948
+ return;
949
+ }
950
+ case "Filter": {
951
+ visitExpr(forest, expr.parent);
952
+ const path = paintAbs(forest, graphqlBase(expr.parent));
953
+ paintRel(forest, expr.predicate, selAt(forest.roots, path));
954
+ return;
955
+ }
956
+ }
957
+ };
958
+
959
+ const graphqlBase = (expr: Expr): Expr => {
960
+ switch (expr._tag) {
961
+ case "Root":
962
+ case "Prop":
963
+ case "Item":
964
+ return expr;
965
+ case "MapValue":
966
+ case "MapItems":
967
+ case "Filter":
968
+ case "FlatMap":
969
+ return graphqlBase(expr.parent);
970
+ case "Literal":
971
+ throw new GqlError("literal has no graphql base");
972
+ }
973
+ };
974
+
975
+ const visitRel = (plan: unknown, forest: Forest, listSel: SelNode): void => {
976
+ if (isQuery(plan)) {
977
+ paintRel(forest, (plan as QueryNode).expr, listSel);
978
+ return;
979
+ }
980
+ if (plan !== null && typeof plan === "object" && !Effect.isEffect(plan)) {
981
+ for (const nested of Object.values(plan as Record<string, unknown>)) {
982
+ visitRel(nested, forest, listSel);
983
+ }
984
+ }
985
+ };
986
+
987
+ /**
988
+ * Recurse through a builder return value. Query nodes contribute GraphQL
989
+ * fields to `forest`; Effects are ignored until interpret (FlatMap).
990
+ */
991
+ const visitPlan = (plan: unknown, forest: Forest): void => {
992
+ if (isQuery(plan)) {
993
+ log("visit", printExpr((plan as QueryNode).expr));
994
+ visitExpr(forest, (plan as QueryNode).expr);
995
+ return;
996
+ }
997
+ if (Effect.isEffect(plan)) return;
998
+ if (plan !== null && typeof plan === "object" && !Array.isArray(plan)) {
999
+ for (const nested of Object.values(plan as Record<string, unknown>)) {
1000
+ visitPlan(nested, forest);
1001
+ }
1002
+ }
1003
+ };
1004
+
1005
+ const validate = (node: SelNode): string | undefined => {
1006
+ for (const child of node.children.values()) {
1007
+ if (!child.isScalar && child.children.size === 0) {
1008
+ return `field "${child.field}" has no sub-selection`;
1009
+ }
1010
+ const inner = validate(child);
1011
+ if (inner) return inner;
1012
+ }
1013
+ return undefined;
1014
+ };
1015
+
1016
+ const printChildren = (node: SelNode, indent: number): string => {
1017
+ const pad = " ".repeat(indent);
1018
+ const lines: Array<string> = [];
1019
+ for (const child of node.children.values()) {
1020
+ let head = child.alias ? `${child.alias}: ${child.field}` : child.field;
1021
+ if (child.args && Object.keys(child.args).length > 0) {
1022
+ const printed = Object.entries(child.args)
1023
+ .map(([argName, varName]) => `${argName}: $${varName}`)
1024
+ .join(", ");
1025
+ head += `(${printed})`;
1026
+ }
1027
+ if (child.isScalar) lines.push(`${pad}${head}`);
1028
+ else {
1029
+ lines.push(`${pad}${head} {`);
1030
+ lines.push(printChildren(child, indent + 1));
1031
+ lines.push(`${pad}}`);
1032
+ }
1033
+ }
1034
+ return lines.join("\n");
1035
+ };
1036
+
1037
+ const emptyForest = (): Forest => ({
1038
+ kind: undefined,
1039
+ roots: emptySel(""),
1040
+ vars: new Map(),
1041
+ varI: 0,
1042
+ aliasI: 0,
1043
+ rootAlias: new WeakMap(),
1044
+ rootErrors: new Map(),
1045
+ });
1046
+
1047
+ const emit = (forest: Forest): Compiled => {
1048
+ const err = validate(forest.roots);
1049
+ if (err) throw new GqlError(err);
1050
+ const kind = forest.kind ?? "query";
1051
+ const varDefs = [...forest.vars.entries()]
1052
+ .map(([name, binding]) => `$${name}: ${binding.type}`)
1053
+ .join(", ");
1054
+ const header = varDefs.length > 0 ? `${kind} Gql(${varDefs})` : `${kind} Gql`;
1055
+ const variables: Record<string, unknown> = {};
1056
+ for (const [name, binding] of forest.vars) variables[name] = binding.value;
643
1057
  return {
644
- query,
1058
+ document: `${header} {\n${printChildren(forest.roots, 1)}\n}`,
1059
+ operationName: "Gql",
645
1060
  variables,
646
- operationName,
647
1061
  kind,
648
- rootType,
649
- positions: selected.positions,
1062
+ tree: forest.roots,
1063
+ rootAlias: forest.rootAlias,
1064
+ rootErrors: forest.rootErrors,
650
1065
  };
651
1066
  };
652
1067
 
653
- const decodeFailure = (
654
- message: string,
655
- path: ReadonlyArray<string | number>,
656
- ): never => {
657
- throw new GraphQLDecodeError({ message, path });
1068
+ type Compiled = CompiledOperation & {
1069
+ readonly rootAlias: WeakMap<object, string>;
1070
+ readonly rootErrors: ReadonlyMap<string, RootErrors>;
1071
+ };
1072
+
1073
+ const nodesFromConnection = (value: unknown): unknown => {
1074
+ if (value == null) return value;
1075
+ if (Array.isArray(value)) return value.map(nodesFromConnection);
1076
+ const edges = (value as Record<string, unknown>).edges;
1077
+ if (!Array.isArray(edges)) return [];
1078
+ return edges.map((edge) =>
1079
+ edge == null ? edge : (edge as Record<string, unknown>).node,
1080
+ );
1081
+ };
1082
+
1083
+ const readField = (parent: unknown, name: string): unknown => {
1084
+ if (parent == null) return parent;
1085
+ if (Array.isArray(parent)) {
1086
+ return parent.map((item) =>
1087
+ item == null ? item : (item as Record<string, unknown>)[name],
1088
+ );
1089
+ }
1090
+ return (parent as Record<string, unknown>)[name];
658
1091
  };
659
- /** Validate only selected fields. Preserve the schema's nullability exactly. */
660
- const decodeData = (
661
- model: GraphQLModel,
662
- ref: string,
1092
+
1093
+ const extractAbs = (
1094
+ expr: Expr,
663
1095
  data: unknown,
664
- positions: readonly Position[],
665
- branches: Readonly<Record<string, readonly Position[]>>,
666
- path: ReadonlyArray<string | number>,
1096
+ rootAlias: WeakMap<object, string>,
667
1097
  ): unknown => {
668
- if (data === null) {
669
- if (ref.endsWith("!"))
670
- return decodeFailure(`Non-null ${ref} was null`, path);
671
- return null;
1098
+ switch (expr._tag) {
1099
+ case "Root": {
1100
+ const raw = (data as Record<string, unknown>)[
1101
+ rootAlias.get(expr) ?? expr.field
1102
+ ];
1103
+ return expr.connection ? nodesFromConnection(raw) : raw;
1104
+ }
1105
+ case "Prop": {
1106
+ const value = readField(
1107
+ extractAbs(expr.parent, data, rootAlias),
1108
+ expr.field.name,
1109
+ );
1110
+ return expr.field.kind === "connection"
1111
+ ? nodesFromConnection(value)
1112
+ : value;
1113
+ }
1114
+ default:
1115
+ throw new GqlError(`extractAbs: ${expr._tag}`);
672
1116
  }
673
- if (data === undefined) return decodeFailure(`Missing selected ${ref}`, path);
674
- const base = ref.endsWith("!") ? ref.slice(0, -1) : ref;
675
- if (base.startsWith("[")) {
676
- if (!Array.isArray(data))
677
- return decodeFailure(`Expected ${ref} array`, path);
678
- return data.map((v, i) =>
679
- decodeData(model, base.slice(1, -1), v, positions, branches, [
680
- ...path,
681
- i,
682
- ]),
683
- );
1117
+ };
1118
+
1119
+ const extractRel = (expr: Expr, item: unknown): unknown => {
1120
+ switch (expr._tag) {
1121
+ case "Item":
1122
+ return item;
1123
+ case "Prop": {
1124
+ const value = readField(extractRel(expr.parent, item), expr.field.name);
1125
+ return expr.field.kind === "connection"
1126
+ ? nodesFromConnection(value)
1127
+ : value;
1128
+ }
1129
+ default:
1130
+ throw new GqlError(`extractRel: ${expr._tag}`);
684
1131
  }
685
- const type = model.types[base];
686
- if (!type) return decodeFailure(`Unknown response type ${base}`, path);
687
- if (type.kind === "SCALAR") {
688
- const primitive =
689
- type.scalar ??
690
- (
691
- {
692
- String: "string",
693
- ID: "string",
694
- Boolean: "boolean",
695
- Int: "number",
696
- Float: "number",
697
- } as Record<string, string>
698
- )[base];
699
- if (
700
- primitive === "string | number" &&
701
- typeof data !== "string" &&
702
- typeof data !== "number"
703
- )
704
- return decodeFailure(`Expected ${base}`, path);
705
- if (
706
- base === "BigInt" &&
707
- ((typeof data === "number" && !Number.isSafeInteger(data)) ||
708
- (typeof data === "string" && !/^-?\d+$/.test(data)))
709
- )
710
- return decodeFailure(`Invalid ${base}`, path);
711
- if (
712
- ["string", "number", "boolean"].includes(primitive ?? "") &&
713
- typeof data !== primitive
714
- )
715
- return decodeFailure(`Expected ${base}`, path);
716
- if (
717
- typeof data === "number" &&
718
- (!Number.isFinite(data) ||
719
- (base === "Int" &&
720
- (!Number.isInteger(data) || data < -2147483648 || data > 2147483647)))
721
- )
722
- return decodeFailure(`Invalid ${base}`, path);
723
- return data;
1132
+ };
1133
+
1134
+ const inItem = (expr: Expr): boolean => {
1135
+ switch (expr._tag) {
1136
+ case "Item":
1137
+ return true;
1138
+ case "Prop":
1139
+ case "MapValue":
1140
+ case "MapItems":
1141
+ case "Filter":
1142
+ case "FlatMap":
1143
+ return inItem(expr.parent);
1144
+ default:
1145
+ return false;
724
1146
  }
725
- if (type.kind === "ENUM") {
726
- if (typeof data !== "string" || !type.enumValues?.includes(data))
727
- return decodeFailure(`Invalid ${base} enum`, path);
728
- return data;
1147
+ };
1148
+
1149
+ const interpretExpr = (
1150
+ expr: Expr,
1151
+ data: unknown,
1152
+ rootAlias: WeakMap<object, string>,
1153
+ item: unknown,
1154
+ ): unknown => {
1155
+ switch (expr._tag) {
1156
+ case "Literal":
1157
+ return expr.value;
1158
+ case "Item":
1159
+ return item;
1160
+ case "Root":
1161
+ case "Prop":
1162
+ return inItem(expr)
1163
+ ? extractRel(expr, item)
1164
+ : extractAbs(expr, data, rootAlias);
1165
+ case "MapValue":
1166
+ return expr.mapFn(interpretExpr(expr.parent, data, rootAlias, item));
1167
+ case "Filter": {
1168
+ const list = interpretExpr(expr.parent, data, rootAlias, item);
1169
+ const arr = Array.isArray(list) ? list : [];
1170
+ return arr.filter((row) =>
1171
+ Boolean(interpretExpr(expr.predicate, data, rootAlias, row)),
1172
+ );
1173
+ }
1174
+ case "MapItems": {
1175
+ const list = interpretExpr(expr.parent, data, rootAlias, item);
1176
+ if (expr.single) {
1177
+ return list == null
1178
+ ? null
1179
+ : interpretValueSync(expr.mapped, data, rootAlias, list);
1180
+ }
1181
+ const arr = Array.isArray(list) ? list : [];
1182
+ return arr.map((row) =>
1183
+ interpretValueSync(expr.mapped, data, rootAlias, row),
1184
+ );
1185
+ }
1186
+ case "FlatMap":
1187
+ throw new GqlError("FlatMap must be interpreted as Effect");
729
1188
  }
730
- if (!object(data)) return decodeFailure(`Expected ${base} object`, path);
731
- if (
732
- type.possibleTypes?.length &&
733
- (typeof data.__typename !== "string" ||
734
- !type.possibleTypes.includes(data.__typename))
735
- )
736
- return decodeFailure(`Missing or invalid ${base} __typename`, path);
737
- const active = [
738
- ...positions,
739
- ...(typeof data.__typename === "string"
740
- ? (branches[data.__typename] ?? [])
741
- : []),
742
- ];
743
- const result: Record<string, unknown> = {};
744
- const merged = new Map<string, Position>();
745
- for (const position of active) {
746
- if (position.conditional) continue;
747
- const previous = merged.get(position.key);
748
- merged.set(
749
- position.key,
750
- previous
751
- ? {
752
- ...position,
753
- children: [...previous.children, ...position.children],
754
- branches: Object.fromEntries(
755
- [
756
- ...new Set([
757
- ...Object.keys(previous.branches),
758
- ...Object.keys(position.branches),
759
- ]),
760
- ].map((key) => [
761
- key,
762
- [
763
- ...(previous.branches[key] ?? []),
764
- ...(position.branches[key] ?? []),
765
- ],
766
- ]),
767
- ),
768
- }
769
- : position,
770
- );
1189
+ };
1190
+
1191
+ const interpretValueSync = (
1192
+ plan: unknown,
1193
+ data: unknown,
1194
+ rootAlias: WeakMap<object, string>,
1195
+ item: unknown,
1196
+ ): unknown => {
1197
+ if (isQuery(plan)) {
1198
+ return interpretExpr((plan as QueryNode).expr, data, rootAlias, item);
771
1199
  }
772
- for (const position of merged.values()) {
773
- if (position.conditional) continue;
774
- if (position.typename) {
775
- if (
776
- typeof data[position.key] !== "string" ||
777
- (type.kind === "OBJECT" && data[position.key] !== base)
1200
+ if (plan !== null && typeof plan === "object" && !Effect.isEffect(plan)) {
1201
+ const out: Record<string, unknown> = {};
1202
+ for (const [key, nested] of Object.entries(
1203
+ plan as Record<string, unknown>,
1204
+ )) {
1205
+ out[key] = interpretValueSync(nested, data, rootAlias, item);
1206
+ }
1207
+ return out;
1208
+ }
1209
+ return plan;
1210
+ };
1211
+
1212
+ const interpretPlan = (
1213
+ plan: unknown,
1214
+ data: unknown,
1215
+ rootAlias: WeakMap<object, string>,
1216
+ ): Effect.Effect<unknown, unknown, GqlTransport> =>
1217
+ Effect.gen(function* () {
1218
+ if (isQuery(plan)) {
1219
+ const expr = (plan as QueryNode).expr;
1220
+ if (expr._tag === "FlatMap") {
1221
+ const resolved = interpretExpr(expr.parent, data, rootAlias, undefined);
1222
+ const next = expr.flatMapFn(resolved);
1223
+ if (Effect.isEffect(next)) return yield* next;
1224
+ if (isQuery(next)) return yield* interpretPlan(next, data, rootAlias);
1225
+ return next;
1226
+ }
1227
+ return interpretExpr(expr, data, rootAlias, undefined);
1228
+ }
1229
+ if (Effect.isEffect(plan)) return yield* plan;
1230
+ if (plan !== null && typeof plan === "object" && !Array.isArray(plan)) {
1231
+ const out: Record<string, unknown> = {};
1232
+ for (const [key, nested] of Object.entries(
1233
+ plan as Record<string, unknown>,
1234
+ )) {
1235
+ out[key] = yield* interpretPlan(nested, data, rootAlias);
1236
+ }
1237
+ return out;
1238
+ }
1239
+ return plan;
1240
+ }) as Effect.Effect<unknown, unknown, GqlTransport>;
1241
+
1242
+ /**
1243
+ * Compile every Query in `plan` into one document, POST it, then replace
1244
+ * Query/Effect leaves with plain data (`UnwrapPlan`).
1245
+ */
1246
+ const evaluatePlan = <Plan>(
1247
+ plan: Plan,
1248
+ ): Effect.Effect<UnwrapPlan<Plan>, QueryError<PlanError<Plan>>, GqlTransport> =>
1249
+ Effect.gen(function* () {
1250
+ const forest = emptyForest();
1251
+ visitPlan(plan, forest);
1252
+ let data: unknown = {};
1253
+ let rootAlias: WeakMap<object, string> = new WeakMap();
1254
+ if (forest.roots.children.size > 0) {
1255
+ const compiled = emit(forest);
1256
+ log("document\n" + compiled.document);
1257
+ data = yield* execute(compiled);
1258
+ rootAlias = compiled.rootAlias;
1259
+ }
1260
+ return (yield* interpretPlan(plan, data, rootAlias)) as UnwrapPlan<Plan>;
1261
+ }) as Effect.Effect<
1262
+ UnwrapPlan<Plan>,
1263
+ QueryError<PlanError<Plan>>,
1264
+ GqlTransport
1265
+ >;
1266
+
1267
+ /**
1268
+ * POST one compiled document and fail with its classified errors. Queries
1269
+ * are retried (bounded) while every failure is retryable; mutations never
1270
+ * are, since an error can follow a side effect that already happened.
1271
+ */
1272
+ const execute = (
1273
+ compiled: Compiled,
1274
+ ): Effect.Effect<unknown, ExecuteError, GqlTransport> => {
1275
+ const once = Effect.gen(function* () {
1276
+ const transport = yield* GqlTransport;
1277
+ const response = yield* transport.execute(compiled);
1278
+ const raw = response.errors ?? [];
1279
+ if (raw.length === 0) return response.data;
1280
+ const issues = raw.map((error) => classify(compiled, error, response));
1281
+ const [first, ...rest] = issues as [GraphQLIssue, ...GraphQLIssue[]];
1282
+ return yield* Effect.fail(
1283
+ rest.every((issue) => issue._tag === first._tag)
1284
+ ? first
1285
+ : new GraphQLFailure({
1286
+ errors: [first, ...rest],
1287
+ data: response.data,
1288
+ status: response.status,
1289
+ }),
1290
+ );
1291
+ });
1292
+ return compiled.kind === "query"
1293
+ ? once.pipe(
1294
+ Effect.retry({
1295
+ while: (error) => isRetryable(compiled, error),
1296
+ times: 5,
1297
+ schedule: Schedule.exponential("200 millis"),
1298
+ }),
778
1299
  )
779
- return decodeFailure(`Invalid __typename for ${base}`, [
780
- ...path,
781
- position.key,
782
- ]);
783
- Object.defineProperty(result, position.key, {
784
- value: data[position.key],
785
- enumerable: true,
786
- configurable: true,
787
- writable: true,
788
- });
789
- } else
790
- Object.defineProperty(result, position.key, {
791
- value: decodeData(
792
- model,
793
- position.type,
794
- data[position.key],
795
- position.children,
796
- position.branches,
797
- [...path, position.key],
798
- ),
799
- enumerable: true,
800
- configurable: true,
801
- writable: true,
802
- });
1300
+ : once;
1301
+ };
1302
+
1303
+ type ExecuteError = GraphQLIssue | GraphQLFailure | GraphQLTransportError;
1304
+
1305
+ const isRetryable = (compiled: Compiled, error: ExecuteError): boolean => {
1306
+ if (error instanceof GraphQLTransportError) {
1307
+ return (
1308
+ error.status === undefined || error.status === 429 || error.status >= 500
1309
+ );
803
1310
  }
804
- return result;
1311
+ const retryable = new Set(
1312
+ [...compiled.rootErrors.values()].flatMap((root) =>
1313
+ root.errors.filter((spec) => spec.retryable).map((spec) => spec.tag),
1314
+ ),
1315
+ );
1316
+ return error instanceof GraphQLFailure
1317
+ ? error.errors.every((issue) => retryable.has(issue._tag))
1318
+ : retryable.has(error._tag);
805
1319
  };
806
1320
 
807
- type ErrorConstructor = new (args: any) => GraphQLIssue;
1321
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
1322
+ typeof value === "object" && value !== null && !Array.isArray(value);
1323
+
1324
+ /**
1325
+ * Pick the most specific matcher among the errors the root at `path[0]`
1326
+ * declares (or the global errors for a path-less error). A tie between
1327
+ * different tags is ambiguous and stays {@link UnknownGraphQLError}.
1328
+ */
808
1329
  const classify = (
809
- model: GraphQLModel,
810
- compiled: CompiledQuery,
811
- raw: Record<string, any>,
1330
+ compiled: Compiled,
1331
+ raw: RawGraphQLError,
812
1332
  response: GraphQLResponse,
813
- classes: Readonly<Record<string, ErrorConstructor>>,
814
1333
  ): GraphQLIssue => {
815
- const path = Array.isArray(raw.path)
816
- ? (raw.path as (string | number)[])
817
- : undefined;
818
- let candidates = compiled.positions;
819
- let position: Position | undefined;
820
- let currentData: unknown = object(response.body)
821
- ? response.body.data
822
- : undefined;
823
- let parents: readonly Position[] = [];
824
- const descendants = (
825
- positions: readonly Position[],
826
- value: unknown,
827
- ): readonly Position[] =>
828
- positions.flatMap((p) => [
829
- ...p.children,
830
- ...(object(value) && typeof value.__typename === "string"
831
- ? (p.branches[value.__typename] ?? [])
832
- : Object.values(p.branches).flat()),
833
- ]);
834
- let valid = !!path?.length;
835
- for (const segment of path ?? []) {
836
- if (typeof segment === "number") {
837
- currentData = Array.isArray(currentData)
838
- ? currentData[segment]
839
- : undefined;
840
- candidates = descendants(parents, currentData);
841
- continue;
842
- }
843
- const matches = candidates.filter((p) => p.key === segment);
844
- if (!matches.length) {
845
- valid = false;
846
- break;
847
- }
848
- position = {
849
- ...matches[0]!,
850
- errors: [...new Set(matches.flatMap((p) => p.errors))],
851
- };
852
- currentData = object(currentData) ? currentData[segment] : undefined;
853
- parents = matches;
854
- candidates = descendants(matches, currentData);
855
- }
856
- const extensions = object(raw.extensions) ? raw.extensions : {};
1334
+ const path = Array.isArray(raw.path) ? raw.path : undefined;
1335
+ const root =
1336
+ typeof path?.[0] === "string"
1337
+ ? compiled.rootErrors.get(path[0])
1338
+ : undefined;
1339
+ const candidates = root
1340
+ ? root.errors
1341
+ : uniqueByTag(
1342
+ [...compiled.rootErrors.values()].flatMap((entry) =>
1343
+ entry.errors.filter((spec) => spec.global),
1344
+ ),
1345
+ );
1346
+ const extensions = isRecord(raw.extensions) ? raw.extensions : {};
857
1347
  const code =
858
1348
  typeof extensions.code === "string"
859
1349
  ? extensions.code
860
1350
  : typeof extensions.errorCode === "string"
861
1351
  ? extensions.errorCode
862
1352
  : undefined;
863
- const retryHeader = response.headers["retry-after"];
864
- const retryAfter =
865
- retryHeader && /^\d+(\.\d+)?$/.test(retryHeader)
866
- ? Number(retryHeader)
867
- : undefined;
868
- const props = {
869
- message: raw.message,
1353
+ const message = typeof raw.message === "string" ? raw.message : "";
1354
+ const retryHeader = response.headers?.["retry-after"];
1355
+ const props: GraphQLIssueProps = {
1356
+ message,
870
1357
  code,
871
1358
  path,
872
1359
  locations: raw.locations,
@@ -878,23 +1365,22 @@ const classify = (
878
1365
  ? raw.traceId
879
1366
  : undefined,
880
1367
  status: response.status,
881
- retryAfter,
1368
+ retryAfter:
1369
+ retryHeader && /^\d+(\.\d+)?$/.test(retryHeader)
1370
+ ? Number(retryHeader)
1371
+ : undefined,
882
1372
  };
883
- const tags = [
884
- ...model.globalErrors,
885
- ...(valid ? (position?.errors ?? []) : []),
886
- ];
887
- const matches = tags.flatMap((tag) =>
888
- (model.errors[tag]?.matchers ?? [])
1373
+ const matches = candidates.flatMap((spec) =>
1374
+ spec.matchers
889
1375
  .filter(
890
1376
  (matcher) =>
891
1377
  (matcher.code === undefined || matcher.code === code) &&
892
- (matcher.message === undefined || matcher.message === raw.message) &&
1378
+ (matcher.message === undefined || matcher.message === message) &&
893
1379
  (matcher.messageIncludes === undefined ||
894
- raw.message.includes(matcher.messageIncludes)),
1380
+ message.includes(matcher.messageIncludes)),
895
1381
  )
896
1382
  .map((matcher) => ({
897
- tag,
1383
+ spec,
898
1384
  score:
899
1385
  (matcher.code ? 1 : 0) +
900
1386
  (matcher.message ? 4 : 0) +
@@ -903,419 +1389,358 @@ const classify = (
903
1389
  );
904
1390
  matches.sort((a, b) => b.score - a.score);
905
1391
  const best = matches[0];
906
- const tag =
907
- best && !matches.some((m) => m.score === best.score && m.tag !== best.tag)
908
- ? best.tag
909
- : undefined;
910
- if (tag && classes[tag]) return new classes[tag](props);
1392
+ if (
1393
+ best &&
1394
+ !matches.some(
1395
+ (match) => match.score === best.score && match.spec.tag !== best.spec.tag,
1396
+ )
1397
+ ) {
1398
+ return best.spec.make(props);
1399
+ }
911
1400
  return new UnknownGraphQLError({
912
1401
  ...props,
913
- coordinate: valid ? position?.coordinate : undefined,
1402
+ coordinate: root?.field,
914
1403
  });
915
1404
  };
916
1405
 
917
- export interface Report<A, E extends GraphQLIssue> {
918
- readonly data: A | null | undefined;
919
- readonly errors: ReadonlyArray<E>;
920
- readonly status: number;
921
- readonly extensions?: unknown;
1406
+ const uniqueByTag = (specs: ReadonlyArray<ErrorSpec>): ErrorSpec[] => [
1407
+ ...new Map(specs.map((spec) => [spec.tag, spec])).values(),
1408
+ ];
1409
+
1410
+ /** `Query.of` / pure. */
1411
+ export const ofQuery = <Value>(value: Value): Query<Value> =>
1412
+ make({ _tag: "Literal", value, type: { tag: "scalar" } });
1413
+
1414
+ const filterImpl = (
1415
+ source: QueryNode,
1416
+ predicate: (item: QueryNode) => QueryNode,
1417
+ ): Query<unknown> => {
1418
+ const proto = itemQuery(source);
1419
+ const predicateQuery = predicate(proto);
1420
+ log("filter pred", printExpr(predicateQuery.expr));
1421
+ return make({
1422
+ _tag: "Filter",
1423
+ parent: source.expr,
1424
+ predicate: predicateQuery.expr,
1425
+ type: source.expr.type,
1426
+ });
1427
+ };
1428
+
1429
+ export function filterQuery<Item, Error = never>(
1430
+ predicate: (item: Query<Item>) => QueryNode<boolean, any>,
1431
+ ): (
1432
+ source: QueryNode<readonly Item[] | null, Error>,
1433
+ ) => Query<readonly Item[], Error>;
1434
+ export function filterQuery<Item, Error = never>(
1435
+ source: QueryNode<readonly Item[] | null, Error>,
1436
+ predicate: (item: Query<Item>) => QueryNode<boolean, any>,
1437
+ ): Query<readonly Item[], Error>;
1438
+ export function filterQuery(sourceOrPredicate: any, predicate?: any): any {
1439
+ if (predicate === undefined) {
1440
+ return (source: QueryNode) => filterImpl(source, sourceOrPredicate);
1441
+ }
1442
+ return filterImpl(sourceOrPredicate, predicate);
922
1443
  }
923
- type Failure<C extends Schema, N extends string, Q> =
924
- | ClientError
925
- | GraphQLFailure<Errors<C, N, Q>>;
926
- type FieldAt<
927
- C extends Schema,
928
- N extends string,
929
- K extends string,
930
- > = K extends keyof Fields<C, N> ? Fields<C, N>[K] : never;
931
- type ConnectionEdge<C extends Schema, Ref extends string> = Named<
932
- TypeOf<FieldAt<C, Named<Ref>, "edges">>
933
- >;
934
- type ConnectionNode<C extends Schema, Ref extends string> = TypeOf<
935
- FieldAt<C, ConnectionEdge<C, Ref>, "node">
936
- >;
937
-
938
- export interface ObjectOperation<
939
- C extends Schema,
940
- K extends OperationKind,
941
- N extends keyof Fields<C, Root<C, K>> & string,
942
- R,
943
- > {
944
- <const Q extends Selection<C, Named<TypeOf<Fields<C, Root<C, K>>[N]>>>>(
945
- args: ArgsOf<Fields<C, Root<C, K>>[N]>,
946
- select: Q,
947
- ): Effect.Effect<
948
- Result<C, TypeOf<Fields<C, Root<C, K>>[N]>, Q>,
949
- Failure<C, Root<C, K>, OperationSelection<C, Root<C, K>, N, Q>>,
950
- R
951
- >;
1444
+
1445
+ /**
1446
+ * What `Query.map`'s callback receives: a Query per item for lists, a Query
1447
+ * for an object (so its fields get selected), the plain value for scalars.
1448
+ */
1449
+ type MapInput<Value> = [NonNullable<Value>] extends [ReadonlyArray<infer Item>]
1450
+ ? Query<Item>
1451
+ : [NonNullable<Value>] extends [object]
1452
+ ? Query<NonNullable<Value>>
1453
+ : Value;
1454
+
1455
+ /** An object mapped through a `null` value stays `null`. */
1456
+ type MapOutput<Value, Mapped> = [NonNullable<Value>] extends [
1457
+ ReadonlyArray<unknown>,
1458
+ ]
1459
+ ? ReadonlyArray<UnwrapPlan<Mapped>>
1460
+ : [NonNullable<Value>] extends [object]
1461
+ ? UnwrapPlan<Mapped> | Nullish<Value>
1462
+ : Mapped;
1463
+
1464
+ const mapImpl = (
1465
+ source: QueryNode,
1466
+ mapFn: (value: any) => unknown,
1467
+ ): Query<unknown> => {
1468
+ if (source.expr.type.tag === "object") {
1469
+ const mapped = mapFn(
1470
+ new QueryNode({
1471
+ _tag: "Item",
1472
+ list: source.expr,
1473
+ type: source.expr.type,
1474
+ }),
1475
+ );
1476
+ log("map object", printExpr(source.expr));
1477
+ return make({
1478
+ _tag: "MapItems",
1479
+ parent: source.expr,
1480
+ mapped,
1481
+ single: true,
1482
+ type: { tag: "scalar" },
1483
+ });
1484
+ }
1485
+ if (source.expr.type.tag === "list") {
1486
+ const proto = itemQuery(source);
1487
+ const mapped = mapFn(proto);
1488
+ log("map items", printExpr(source.expr));
1489
+ return make({
1490
+ _tag: "MapItems",
1491
+ parent: source.expr,
1492
+ mapped,
1493
+ type: source.expr.type,
1494
+ });
1495
+ }
1496
+ log("map value", printExpr(source.expr));
1497
+ return make({
1498
+ _tag: "MapValue",
1499
+ parent: source.expr,
1500
+ mapFn,
1501
+ type: { tag: "scalar" },
1502
+ });
1503
+ };
1504
+
1505
+ export function mapQuery<Value, Mapped, Error = never>(
1506
+ mapFn: (value: NoInfer<MapInput<Value>>) => Mapped,
1507
+ ): (source: QueryNode<Value, Error>) => Query<MapOutput<Value, Mapped>, Error>;
1508
+ export function mapQuery<Value, Mapped, Error = never>(
1509
+ source: QueryNode<Value, Error>,
1510
+ mapFn: (value: NoInfer<MapInput<Value>>) => Mapped,
1511
+ ): Query<MapOutput<Value, Mapped>, Error>;
1512
+ export function mapQuery(sourceOrMapFn: any, mapFn?: any): any {
1513
+ if (mapFn === undefined) {
1514
+ return (source: QueryNode) => mapImpl(source, sourceOrMapFn);
1515
+ }
1516
+ return mapImpl(sourceOrMapFn, mapFn);
952
1517
  }
953
- export interface ScalarOperation<
954
- C extends Schema,
955
- K extends OperationKind,
956
- N extends keyof Fields<C, Root<C, K>> & string,
957
- R,
958
- > {
959
- (
960
- args: ArgsOf<Fields<C, Root<C, K>>[N]>,
961
- ): Effect.Effect<
962
- Result<C, TypeOf<Fields<C, Root<C, K>>[N]>, true>,
963
- Failure<C, Root<C, K>, OperationSelection<C, Root<C, K>, N, true>>,
964
- R
965
- >;
1518
+
1519
+ const flatMapImpl = (
1520
+ source: QueryNode,
1521
+ flatMapFn: (value: unknown) => unknown,
1522
+ ): Query<unknown> => {
1523
+ log("flatMap", printExpr(source.expr));
1524
+ return make({
1525
+ _tag: "FlatMap",
1526
+ parent: source.expr,
1527
+ flatMapFn,
1528
+ type: { tag: "scalar" },
1529
+ });
1530
+ };
1531
+
1532
+ export function flatMapQuery<
1533
+ Value,
1534
+ Result,
1535
+ Error,
1536
+ Requirements,
1537
+ SourceError = never,
1538
+ >(
1539
+ flatMapFn: (
1540
+ value: Value,
1541
+ ) => Effect.Effect<Result, Error, Requirements> | QueryNode<Result, Error>,
1542
+ ): (
1543
+ source: QueryNode<Value, SourceError>,
1544
+ ) => Query<Result, SourceError | Error>;
1545
+ export function flatMapQuery<
1546
+ Value,
1547
+ Result,
1548
+ Error,
1549
+ Requirements,
1550
+ SourceError = never,
1551
+ >(
1552
+ source: QueryNode<Value, SourceError>,
1553
+ flatMapFn: (
1554
+ value: Value,
1555
+ ) => Effect.Effect<Result, Error, Requirements> | QueryNode<Result, Error>,
1556
+ ): Query<Result, SourceError | Error>;
1557
+ export function flatMapQuery(sourceOrFlatMapFn: any, flatMapFn?: any): any {
1558
+ if (flatMapFn === undefined) {
1559
+ return (source: QueryNode) => flatMapImpl(source, sourceOrFlatMapFn);
1560
+ }
1561
+ return flatMapImpl(sourceOrFlatMapFn, flatMapFn);
966
1562
  }
967
- export interface Pagination<
968
- C extends Schema,
969
- K extends OperationKind,
970
- N extends keyof Fields<C, Root<C, K>> & string,
971
- R,
972
- > {
973
- pages<const Q extends Selection<C, Named<TypeOf<Fields<C, Root<C, K>>[N]>>>>(
974
- args: ArgsOf<Fields<C, Root<C, K>>[N]>,
975
- select: Q,
976
- ): Stream.Stream<
977
- Result<C, TypeOf<Fields<C, Root<C, K>>[N]>, Q>,
978
- | ClientError
979
- | GraphQLFailure<
980
- | Errors<C, Root<C, K>, OperationSelection<C, Root<C, K>, N, Q>>
981
- | Errors<
982
- C,
983
- Named<TypeOf<Fields<C, Root<C, K>>[N]>>,
984
- { pageInfo: { endCursor: true; hasNextPage: true } }
985
- >
986
- >,
987
- R
988
- >;
989
- items<
990
- const Q extends Selection<
991
- C,
992
- Named<ConnectionNode<C, TypeOf<Fields<C, Root<C, K>>[N]>>>
993
- >,
994
- >(
995
- args: ArgsOf<Fields<C, Root<C, K>>[N]>,
996
- select: Q,
997
- ): Stream.Stream<
998
- Result<C, ConnectionNode<C, TypeOf<Fields<C, Root<C, K>>[N]>>, Q>,
999
- Failure<
1000
- C,
1001
- Root<C, K>,
1002
- OperationSelection<
1003
- C,
1004
- Root<C, K>,
1005
- N,
1006
- { edges: { node: Q }; pageInfo: { endCursor: true; hasNextPage: true } }
1007
- >
1008
- >,
1009
- R
1010
- >;
1563
+
1564
+ /**
1565
+ * Builder → Effect. The inner function is *not* a generator; it returns a
1566
+ * plan of Query/Effect values. Query.fn visits the plan, POSTs one document,
1567
+ * then interprets Map/Filter/FlatMap/Of.
1568
+ */
1569
+ export const queryFn =
1570
+ <Arguments extends Array<unknown>, Plan>(
1571
+ build: (...args: Arguments) => Plan,
1572
+ ): ((
1573
+ ...args: Arguments
1574
+ ) => Effect.Effect<
1575
+ UnwrapPlan<Plan>,
1576
+ QueryError<PlanError<Plan>>,
1577
+ GqlTransport
1578
+ >) =>
1579
+ (...args) => {
1580
+ const plan = build(...args);
1581
+ return evaluatePlan(plan);
1582
+ };
1583
+
1584
+ // ── Pagination ──────────────────────────────────────────────────────────────
1585
+ //
1586
+ // A connection lens hides `edges`/`pageInfo`. `Query.pages` re-evaluates the
1587
+ // query once per page: it sets the connection's `after` argument, paints
1588
+ // `pageInfo { hasNextPage endCursor }` next to `edges`, and stops when
1589
+ // `hasNextPage` is false.
1590
+
1591
+ /** The connection a paged query reads, below any list-level Map/Filter. */
1592
+ const connectionOf = (expr: Expr): RootExpr | PropExpr => {
1593
+ const base = graphqlBase(expr);
1594
+ const isConnection =
1595
+ (base._tag === "Root" && base.connection === true) ||
1596
+ (base._tag === "Prop" && base.field.kind === "connection");
1597
+ if (!isConnection || inItem(base)) {
1598
+ throw new GqlError(
1599
+ `Query.pages needs a Relay connection, got ${printExpr(base)}`,
1600
+ );
1601
+ }
1602
+ const argTypes = base._tag === "Root" ? base.argTypes : base.field.argTypes;
1603
+ if (!argTypes || !("after" in argTypes)) {
1604
+ throw new GqlError(`${printExpr(base)} takes no "after" cursor`);
1605
+ }
1606
+ return base as RootExpr | PropExpr;
1607
+ };
1608
+
1609
+ /** Rebuild `expr` with the connection's `after` argument set. */
1610
+ const withCursor = (
1611
+ expr: Expr,
1612
+ connection: Expr,
1613
+ after: string | undefined,
1614
+ ): Expr => {
1615
+ if (expr === connection) {
1616
+ const current = expr as RootExpr | PropExpr;
1617
+ return { ...current, args: { ...current.args, after } } as Expr;
1618
+ }
1619
+ switch (expr._tag) {
1620
+ case "MapItems":
1621
+ case "Filter":
1622
+ case "MapValue":
1623
+ case "FlatMap":
1624
+ return { ...expr, parent: withCursor(expr.parent, connection, after) };
1625
+ default:
1626
+ throw new GqlError(`withCursor: ${expr._tag}`);
1627
+ }
1628
+ };
1629
+
1630
+ const readPath = (data: unknown, path: Path): unknown => {
1631
+ let current = data;
1632
+ for (const segment of path) {
1633
+ if (current == null) return undefined;
1634
+ if (Array.isArray(current)) {
1635
+ throw new GqlError("Query.pages cannot page a connection inside a list");
1636
+ }
1637
+ current = (current as Record<string, unknown>)[segment];
1638
+ }
1639
+ return current;
1640
+ };
1641
+
1642
+ interface PageState {
1643
+ readonly after: string | undefined;
1644
+ readonly seen: ReadonlySet<string>;
1011
1645
  }
1012
- export type Operation<
1013
- C extends Schema,
1014
- K extends OperationKind,
1015
- N extends keyof Fields<C, Root<C, K>> & string,
1016
- R,
1017
- > =
1018
- Named<TypeOf<Fields<C, Root<C, K>>[N]>> extends keyof C["scalars"]
1019
- ? ScalarOperation<C, K, N, R>
1020
- : ObjectOperation<C, K, N, R> &
1021
- (K extends "query"
1022
- ? "after" extends keyof ArgsOf<Fields<C, Root<C, K>>[N]>
1023
- ? [ConnectionNode<C, TypeOf<Fields<C, Root<C, K>>[N]>>] extends [
1024
- never,
1025
- ]
1026
- ? {}
1027
- : Pagination<C, K, N, R>
1028
- : {}
1029
- : {});
1030
-
1031
- /** Construct an Effect client; credentials and HTTP are resolved by the transport per call. */
1032
- export const makeClient = <C extends Schema, R>(
1033
- model: GraphQLModel,
1034
- transport: Transport<R>,
1035
- classes: Readonly<Record<string, ErrorConstructor>>,
1036
- ) => {
1037
- const executeReport = (
1038
- kind: OperationKind,
1039
- selection: unknown,
1040
- ): Effect.Effect<Report<any, GraphQLIssue>, ClientError, R> =>
1041
- Effect.gen(function* () {
1042
- const compiled = yield* Effect.try({
1043
- try: () => compile(model, kind, selection),
1044
- catch: (cause) =>
1045
- cause instanceof GraphQLRequestError
1046
- ? cause
1047
- : new GraphQLRequestError({ message: String(cause) }),
1646
+
1647
+ const fetchPage = <Item>(
1648
+ query: QueryNode<ReadonlyArray<Item> | null, unknown>,
1649
+ connection: RootExpr | PropExpr,
1650
+ state: PageState,
1651
+ ) =>
1652
+ Effect.gen(function* () {
1653
+ const pageExpr = withCursor(query.expr, connection, state.after);
1654
+ const forest = emptyForest();
1655
+ visitExpr(forest, pageExpr);
1656
+ const nodePath = paintAbs(
1657
+ forest,
1658
+ withCursor(connection, connection, state.after),
1659
+ );
1660
+ const connectionPath = nodePath.slice(0, -2);
1661
+ const pageInfo = ensureChild(
1662
+ forest,
1663
+ selAt(forest.roots, connectionPath),
1664
+ "pageInfo",
1665
+ {},
1666
+ );
1667
+ ensureChild(forest, pageInfo, "hasNextPage", { isScalar: true });
1668
+ ensureChild(forest, pageInfo, "endCursor", { isScalar: true });
1669
+ const compiled = emit(forest);
1670
+ log("page\n" + compiled.document);
1671
+ const data = yield* execute(compiled);
1672
+ const items = (yield* interpretPlan(
1673
+ make(pageExpr),
1674
+ data,
1675
+ compiled.rootAlias,
1676
+ )) as ReadonlyArray<Item> | null | undefined;
1677
+ const info = readPath(data, [...connectionPath, "pageInfo"]) as
1678
+ | { hasNextPage?: boolean; endCursor?: string | null }
1679
+ | null
1680
+ | undefined;
1681
+ const cursor = info?.endCursor ?? null;
1682
+ if (info?.hasNextPage !== true) {
1683
+ return [items ?? [], Option.none<PageState>()] as const;
1684
+ }
1685
+ if (cursor === null || cursor === state.after || state.seen.has(cursor)) {
1686
+ return yield* new GraphQLPaginationError({
1687
+ message: `${printExpr(connection)} returned a non-advancing cursor`,
1688
+ cursor,
1048
1689
  });
1049
- const response = yield* transport(compiled);
1050
- return yield* Effect.try({
1051
- try: () => {
1052
- const body = response.body;
1053
- if (!object(body))
1054
- return decodeFailure("GraphQL response must be an object", []);
1055
- if (
1056
- body.errors !== undefined &&
1057
- (!Array.isArray(body.errors) || !body.errors.length)
1058
- )
1059
- return decodeFailure("GraphQL errors must be a nonempty array", []);
1060
- const rawErrors = body.errors ?? [];
1061
- for (const error of rawErrors) {
1062
- if (!object(error) || typeof error.message !== "string")
1063
- return decodeFailure("Invalid GraphQL error", []);
1064
- if (
1065
- error.path !== undefined &&
1066
- (!Array.isArray(error.path) ||
1067
- error.path.some(
1068
- (p: unknown) =>
1069
- typeof p !== "string" &&
1070
- (typeof p !== "number" || !Number.isInteger(p) || p < 0),
1071
- ))
1072
- )
1073
- return decodeFailure("Invalid GraphQL error path", []);
1074
- }
1075
- if (!("data" in body) && !rawErrors.length) {
1076
- if (response.status >= 400)
1077
- throw new GraphQLTransportError({
1078
- message: `HTTP ${response.status} without a GraphQL response`,
1079
- status: response.status,
1080
- });
1081
- return decodeFailure(
1082
- "GraphQL response has neither data nor errors",
1083
- [],
1084
- );
1085
- }
1086
- if (response.status >= 400 && !rawErrors.length)
1087
- throw new GraphQLTransportError({
1088
- message: `HTTP ${response.status} without GraphQL errors`,
1089
- status: response.status,
1090
- });
1091
- const errors = rawErrors.map((raw: Record<string, any>) =>
1092
- classify(model, compiled, raw, response, classes),
1093
- );
1094
- let data = body.data;
1095
- if (data !== undefined && data !== null)
1096
- data = decodeData(
1097
- model,
1098
- compiled.rootType + "!",
1099
- data,
1100
- compiled.positions,
1101
- {},
1102
- [],
1103
- );
1104
- if ((data === undefined || data === null) && !errors.length)
1105
- return decodeFailure("GraphQL success has no data", []);
1106
- return {
1107
- data,
1108
- errors,
1109
- status: response.status,
1110
- extensions: body.extensions,
1111
- };
1690
+ }
1691
+ return [
1692
+ items ?? [],
1693
+ Option.some({ after: cursor, seen: new Set([...state.seen, cursor]) }),
1694
+ ] as const;
1695
+ });
1696
+
1697
+ /**
1698
+ * Every page of a Relay connection, one request per page. `query` is a
1699
+ * connection lens (root or nested under objects), optionally mapped or
1700
+ * filtered: `Query.pages(Railway.project({ id }).services({ first: 50 }))`.
1701
+ * Starts from the query's own `after` argument, if any.
1702
+ */
1703
+ export const pagesQuery = <Item, Error>(
1704
+ query: QueryNode<ReadonlyArray<Item> | null, Error>,
1705
+ ): Stream.Stream<
1706
+ ReadonlyArray<Item>,
1707
+ QueryError<Error> | GraphQLPaginationError,
1708
+ GqlTransport
1709
+ > =>
1710
+ Stream.unwrap(
1711
+ Effect.sync(() => {
1712
+ const connection = connectionOf(query.expr);
1713
+ const after = connection.args?.after;
1714
+ return Stream.paginate<
1715
+ PageState,
1716
+ ReadonlyArray<Item>,
1717
+ QueryError<Error> | GraphQLPaginationError,
1718
+ GqlTransport
1719
+ >(
1720
+ {
1721
+ after: typeof after === "string" ? after : undefined,
1722
+ seen: new Set(),
1112
1723
  },
1113
- catch: (cause) =>
1114
- cause instanceof GraphQLDecodeError ||
1115
- cause instanceof GraphQLTransportError
1116
- ? cause
1117
- : new GraphQLDecodeError({
1118
- message: "Invalid GraphQL response",
1119
- cause,
1120
- }),
1121
- });
1122
- });
1123
- const retryable = (error: ClientError | GraphQLFailure): boolean =>
1124
- error instanceof GraphQLFailure
1125
- ? error.errors.every(
1126
- (issue) => model.errors[issue._tag]?.retryable === true,
1127
- )
1128
- : error instanceof GraphQLTransportError &&
1129
- (error.status === undefined ||
1130
- error.status === 429 ||
1131
- error.status >= 500);
1132
- const execute = (
1133
- kind: OperationKind,
1134
- selection: unknown,
1135
- ): Effect.Effect<any, ClientError | GraphQLFailure, R> => {
1136
- const effect = executeReport(kind, selection).pipe(
1137
- Effect.flatMap((report) =>
1138
- report.errors.length
1139
- ? Effect.fail(
1140
- new GraphQLFailure({
1141
- errors: report.errors as [GraphQLIssue, ...GraphQLIssue[]],
1142
- data: report.data,
1143
- status: report.status,
1144
- }),
1145
- )
1146
- : Effect.succeed(report.data),
1147
- ),
1148
- );
1149
- return kind === "query"
1150
- ? effect.pipe(
1151
- Effect.retry({
1152
- while: retryable,
1153
- times: 5,
1154
- schedule: Schedule.exponential("200 millis"),
1155
- }),
1156
- )
1157
- : effect;
1158
- };
1159
- const operation = <
1160
- K extends OperationKind,
1161
- N extends keyof Fields<C, Root<C, K>> & string,
1162
- >(
1163
- kind: K,
1164
- name: N,
1165
- ): Operation<C, K, N, R> => {
1166
- const field =
1167
- model.types[kind === "query" ? model.queryType : model.mutationType!]
1168
- ?.fields?.[name];
1169
- const call = (args: unknown, selection?: unknown) =>
1170
- execute(kind, {
1171
- [name]: { where: args, select: selection ?? true },
1172
- }).pipe(Effect.map((data) => data[name]));
1173
- const pages = (args: any, selection: any) =>
1174
- Stream.unwrap(
1175
- Effect.try({
1176
- try: () => {
1177
- const connection = field && model.types[named(field.type)];
1178
- if (
1179
- kind !== "query" ||
1180
- !field?.args.after ||
1181
- !connection?.fields?.edges ||
1182
- !connection.fields.pageInfo
1183
- )
1184
- return Stream.fromEffect(
1185
- Effect.fail(
1186
- new GraphQLRequestError({
1187
- message: `${name} is not a paginated query connection`,
1188
- }),
1189
- ),
1190
- );
1191
- // Cursor fields are internal additions; the caller's projection remains exact.
1192
- const projection = compile(model, kind, {
1193
- [name]: { where: args, select: selection },
1194
- }).positions[0]!;
1195
- let cursorKey = "pageInfo";
1196
- let complete: Record<string, unknown>;
1197
- if (
1198
- selection.__alias &&
1199
- Object.hasOwn(selection.__alias, "pageInfo")
1200
- ) {
1201
- cursorKey = "_distilledPageInfo";
1202
- while (
1203
- Object.hasOwn(selection, cursorKey) ||
1204
- Object.hasOwn(selection.__alias, cursorKey)
1205
- )
1206
- cursorKey += "_";
1207
- complete = {
1208
- ...selection,
1209
- __alias: {
1210
- ...selection.__alias,
1211
- [cursorKey]: {
1212
- pageInfo: { endCursor: true, hasNextPage: true },
1213
- },
1214
- },
1215
- };
1216
- } else {
1217
- const {
1218
- $include: _include,
1219
- $skip: _skip,
1220
- ...pageFields
1221
- } = selection.pageInfo?.select ?? selection.pageInfo ?? {};
1222
- complete = {
1223
- ...selection,
1224
- pageInfo: { ...pageFields, endCursor: true, hasNextPage: true },
1225
- };
1226
- }
1227
- return Stream.paginate(
1228
- {
1229
- after: args.after as string | undefined,
1230
- seen: new Set<string>(),
1231
- },
1232
- (state) =>
1233
- call({ ...args, after: state.after }, complete).pipe(
1234
- Effect.flatMap((page) => {
1235
- const cursor = page?.[cursorKey]?.endCursor;
1236
- const more = page?.[cursorKey]?.hasNextPage;
1237
- if (
1238
- more &&
1239
- (typeof cursor !== "string" ||
1240
- cursor === state.after ||
1241
- state.seen.has(cursor))
1242
- )
1243
- return Effect.fail(
1244
- new GraphQLDecodeError({
1245
- message: `${name} returned a non-advancing pagination cursor`,
1246
- }),
1247
- );
1248
- const seen = new Set(state.seen);
1249
- if (typeof cursor === "string") seen.add(cursor);
1250
- const projected: any = decodeData(
1251
- model,
1252
- field.type,
1253
- page,
1254
- projection.children,
1255
- projection.branches,
1256
- [],
1257
- );
1258
- return Effect.succeed([
1259
- [projected],
1260
- more
1261
- ? Option.some({ after: cursor, seen })
1262
- : Option.none(),
1263
- ] as const);
1264
- }),
1265
- ),
1266
- );
1267
- },
1268
- catch: (cause) =>
1269
- cause instanceof GraphQLRequestError
1270
- ? cause
1271
- : new GraphQLRequestError({ message: String(cause) }),
1272
- }),
1724
+ (state) =>
1725
+ fetchPage(query, connection, state).pipe(
1726
+ Effect.map(([items, next]) => [[items], next] as const),
1727
+ ) as Effect.Effect<
1728
+ readonly [
1729
+ ReadonlyArray<ReadonlyArray<Item>>,
1730
+ Option.Option<PageState>,
1731
+ ],
1732
+ QueryError<Error> | GraphQLPaginationError,
1733
+ GqlTransport
1734
+ >,
1273
1735
  );
1274
- return Object.assign(call, {
1275
- pages,
1276
- items: (args: unknown, select: unknown) =>
1277
- pages(args, { edges: { node: select } }).pipe(
1278
- Stream.flatMap((page) =>
1279
- Stream.fromIterable(
1280
- (page?.edges ?? []).flatMap((edge: any) =>
1281
- edge === null ? [] : [edge.node],
1282
- ),
1283
- ),
1284
- ),
1285
- ),
1286
- }) as unknown as Operation<C, K, N, R>;
1287
- };
1288
- return {
1289
- query: <const Q extends Selection<C, C["query"]>>(
1290
- selection: Q,
1291
- ): Effect.Effect<
1292
- Result<C, `${C["query"]}!`, Q>,
1293
- Failure<C, C["query"], Q>,
1294
- R
1295
- > => execute("query", selection) as any,
1296
- mutation: <const Q extends Selection<C, C["mutation"]>>(
1297
- selection: Q,
1298
- ): Effect.Effect<
1299
- Result<C, `${C["mutation"]}!`, Q>,
1300
- Failure<C, C["mutation"], Q>,
1301
- R
1302
- > => execute("mutation", selection) as any,
1303
- report: {
1304
- query: <const Q extends Selection<C, C["query"]>>(
1305
- selection: Q,
1306
- ): Effect.Effect<
1307
- Report<Result<C, `${C["query"]}!`, Q>, Errors<C, C["query"], Q>>,
1308
- ClientError,
1309
- R
1310
- > => executeReport("query", selection) as any,
1311
- mutation: <const Q extends Selection<C, C["mutation"]>>(
1312
- selection: Q,
1313
- ): Effect.Effect<
1314
- Report<Result<C, `${C["mutation"]}!`, Q>, Errors<C, C["mutation"], Q>>,
1315
- ClientError,
1316
- R
1317
- > => executeReport("mutation", selection) as any,
1318
- },
1319
- operation,
1320
- };
1321
- };
1736
+ }),
1737
+ );
1738
+
1739
+ /** Every node of a Relay connection, across all pages. */
1740
+ export const itemsQuery = <Item, Error>(
1741
+ query: QueryNode<ReadonlyArray<Item> | null, Error>,
1742
+ ): Stream.Stream<
1743
+ Item,
1744
+ QueryError<Error> | GraphQLPaginationError,
1745
+ GqlTransport
1746
+ > => pagesQuery(query).pipe(Stream.flatMap(Stream.fromIterable));