@distilled.cloud/core 1.0.0-rc.11 → 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.
- package/lib/api.d.ts +4 -4
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +1 -1
- package/lib/api.js.map +1 -1
- package/lib/category.js +1 -1
- package/lib/category.js.map +1 -1
- package/lib/codegen/boolean-string-enums.d.ts +8 -8
- package/lib/codegen/boolean-string-enums.d.ts.map +1 -1
- package/lib/codegen/boolean-string-enums.js +84 -27
- package/lib/codegen/boolean-string-enums.js.map +1 -1
- package/lib/codegen/boolean-string-enums.test.js +130 -0
- package/lib/codegen/boolean-string-enums.test.js.map +1 -1
- package/lib/codegen/cli.js +2 -2
- package/lib/codegen/cli.js.map +1 -1
- package/lib/codegen/generator.d.ts +20 -3
- package/lib/codegen/generator.d.ts.map +1 -1
- package/lib/codegen/generator.js +36 -9
- package/lib/codegen/generator.js.map +1 -1
- package/lib/codegen/generator.test.d.ts +2 -0
- package/lib/codegen/generator.test.d.ts.map +1 -0
- package/lib/codegen/generator.test.js +126 -0
- package/lib/codegen/generator.test.js.map +1 -0
- package/lib/codegen/graphql-client.d.ts +7 -1
- package/lib/codegen/graphql-client.d.ts.map +1 -1
- package/lib/codegen/graphql-client.js +262 -125
- package/lib/codegen/graphql-client.js.map +1 -1
- package/lib/codegen/graphql-client.test.js +104 -236
- package/lib/codegen/graphql-client.test.js.map +1 -1
- package/lib/codegen/openapi-binary.test.d.ts +2 -0
- package/lib/codegen/openapi-binary.test.d.ts.map +1 -0
- package/lib/codegen/openapi-binary.test.js +67 -0
- package/lib/codegen/openapi-binary.test.js.map +1 -0
- package/lib/codegen/openapi.d.ts +7 -1
- package/lib/codegen/openapi.d.ts.map +1 -1
- package/lib/codegen/openapi.js +15 -4
- package/lib/codegen/openapi.js.map +1 -1
- package/lib/codegen/patches.d.ts +16 -1
- package/lib/codegen/patches.d.ts.map +1 -1
- package/lib/codegen/patches.js +70 -4
- package/lib/codegen/patches.js.map +1 -1
- package/lib/codegen/patches.test.js +48 -2
- package/lib/codegen/patches.test.js.map +1 -1
- package/lib/codegen/untagged-unions.test.d.ts +2 -0
- package/lib/codegen/untagged-unions.test.d.ts.map +1 -0
- package/lib/codegen/untagged-unions.test.js +80 -0
- package/lib/codegen/untagged-unions.test.js.map +1 -0
- package/lib/errors.d.ts +2 -1
- package/lib/errors.d.ts.map +1 -1
- package/lib/errors.js +5 -2
- package/lib/errors.js.map +1 -1
- package/lib/fixtures/xml/upstream-validator.json +397 -0
- package/lib/graphql.d.ts +296 -205
- package/lib/graphql.d.ts.map +1 -1
- package/lib/graphql.js +968 -631
- package/lib/graphql.js.map +1 -1
- package/lib/graphql.test.js +134 -763
- package/lib/graphql.test.js.map +1 -1
- package/lib/graphql.types.d.ts +10 -0
- package/lib/graphql.types.d.ts.map +1 -1
- package/lib/graphql.types.js +4 -44
- package/lib/graphql.types.js.map +1 -1
- package/lib/index.d.ts +27 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +27 -0
- package/lib/index.js.map +1 -0
- package/lib/pagination.d.ts +2 -1
- package/lib/pagination.d.ts.map +1 -1
- package/lib/pagination.js +24 -16
- package/lib/pagination.js.map +1 -1
- package/lib/pagination.test.d.ts +2 -0
- package/lib/pagination.test.d.ts.map +1 -0
- package/lib/pagination.test.js +134 -0
- package/lib/pagination.test.js.map +1 -0
- package/lib/protocol-http.d.ts +12 -17
- package/lib/protocol-http.d.ts.map +1 -1
- package/lib/protocol-http.js +100 -45
- package/lib/protocol-http.js.map +1 -1
- package/lib/protocol-http.test.js +130 -0
- package/lib/protocol-http.test.js.map +1 -1
- package/lib/protocol-rest.d.ts +18 -1
- package/lib/protocol-rest.d.ts.map +1 -1
- package/lib/protocol-rest.js +24 -4
- package/lib/protocol-rest.js.map +1 -1
- package/lib/query.d.ts +29 -0
- package/lib/query.d.ts.map +1 -0
- package/lib/query.js +27 -0
- package/lib/query.js.map +1 -0
- package/lib/response-validation.d.ts +67 -0
- package/lib/response-validation.d.ts.map +1 -0
- package/lib/response-validation.js +76 -0
- package/lib/response-validation.js.map +1 -0
- package/lib/response-validation.test.d.ts +2 -0
- package/lib/response-validation.test.d.ts.map +1 -0
- package/lib/response-validation.test.js +67 -0
- package/lib/response-validation.test.js.map +1 -0
- package/lib/testing.d.ts +39 -0
- package/lib/testing.d.ts.map +1 -0
- package/lib/testing.js +41 -0
- package/lib/testing.js.map +1 -0
- package/lib/trait.d.ts +29 -9
- package/lib/trait.d.ts.map +1 -1
- package/lib/trait.js +8 -0
- package/lib/trait.js.map +1 -1
- package/lib/xml.d.ts +44 -0
- package/lib/xml.d.ts.map +1 -0
- package/lib/xml.js +315 -0
- package/lib/xml.js.map +1 -0
- package/lib/xml.test.d.ts +2 -0
- package/lib/xml.test.d.ts.map +1 -0
- package/lib/xml.test.js +211 -0
- package/lib/xml.test.js.map +1 -0
- package/package.json +4 -4
- package/src/api.ts +4 -4
- package/src/category.ts +1 -1
- package/src/codegen/boolean-string-enums.test.ts +147 -0
- package/src/codegen/boolean-string-enums.ts +84 -27
- package/src/codegen/cli.ts +2 -2
- package/src/codegen/generator.test.ts +151 -0
- package/src/codegen/generator.ts +70 -12
- package/src/codegen/graphql-client.test.ts +116 -283
- package/src/codegen/graphql-client.ts +326 -169
- package/src/codegen/openapi-binary.test.ts +98 -0
- package/src/codegen/openapi.ts +26 -4
- package/src/codegen/patches.test.ts +66 -1
- package/src/codegen/patches.ts +84 -4
- package/src/codegen/untagged-unions.test.ts +89 -0
- package/src/errors.ts +5 -2
- package/src/fixtures/xml/upstream-validator.json +397 -0
- package/src/graphql.test.ts +174 -948
- package/src/graphql.ts +1592 -1167
- package/src/graphql.types.ts +92 -163
- package/src/index.ts +26 -0
- package/src/pagination.test.ts +191 -0
- package/src/pagination.ts +35 -25
- package/src/protocol-http.test.ts +163 -0
- package/src/protocol-http.ts +122 -46
- package/src/protocol-rest.ts +62 -13
- package/src/query.ts +39 -0
- package/src/response-validation.test.ts +95 -0
- package/src/response-validation.ts +116 -0
- package/src/testing.ts +78 -0
- package/src/trait.ts +39 -8
- package/src/xml.test.ts +269 -0
- package/src/xml.ts +339 -0
- package/lib/graphql.fixture.d.ts +0 -249
- package/lib/graphql.fixture.d.ts.map +0 -1
- package/lib/graphql.fixture.js +0 -240
- package/lib/graphql.fixture.js.map +0 -1
- package/src/graphql.fixture.ts +0 -371
package/src/graphql.test.ts
CHANGED
|
@@ -1,974 +1,200 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import * as Effect from "effect/Effect";
|
|
3
|
-
import * as
|
|
4
|
-
import * as Stream from "effect/Stream";
|
|
5
|
-
import { Kind, parse } from "graphql";
|
|
6
|
-
import * as G from "./graphql.ts";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
7
4
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
requests.push(request);
|
|
24
|
-
return { body, status, headers: {} };
|
|
25
|
-
}),
|
|
26
|
-
errorClasses,
|
|
27
|
-
);
|
|
28
|
-
return { client, requests };
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const failure = async <A, E>(effect: Effect.Effect<A, E>) => {
|
|
32
|
-
const result = await Effect.runPromise(Effect.result(effect));
|
|
33
|
-
if (Result.isSuccess(result)) throw new Error("Expected a typed failure");
|
|
34
|
-
return result.failure;
|
|
5
|
+
GqlTransport,
|
|
6
|
+
connectionField,
|
|
7
|
+
listField,
|
|
8
|
+
objectField,
|
|
9
|
+
root,
|
|
10
|
+
rootConnection,
|
|
11
|
+
scalarField,
|
|
12
|
+
type TypeMeta,
|
|
13
|
+
} from "./graphql.ts";
|
|
14
|
+
import { Query } from "./query.ts";
|
|
15
|
+
|
|
16
|
+
type UserRow = {
|
|
17
|
+
email: string;
|
|
18
|
+
name: string;
|
|
19
|
+
projects: ReadonlyArray<{ name: string; stars: number }>;
|
|
35
20
|
};
|
|
36
21
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
},
|
|
48
|
-
} as const;
|
|
49
|
-
|
|
50
|
-
describe("GraphQL selection compilation", () => {
|
|
51
|
-
test("only requests chosen fields and serializes arguments as variables", () => {
|
|
52
|
-
const compiled = G.compile(fixtureModel, "query", {
|
|
53
|
-
project: { where: { id: adversarialArgument }, select: { id: true } },
|
|
54
|
-
});
|
|
55
|
-
expect(compiled.query).toMatch(/project\s*\(\s*id\s*:\s*\$/);
|
|
56
|
-
expect(compiled.query).toMatch(/\{\s*id\s*\}/);
|
|
57
|
-
expect(compiled.query).not.toContain("services");
|
|
58
|
-
expect(compiled.query).not.toContain(adversarialArgument);
|
|
59
|
-
expect(Object.values(compiled.variables)).toEqual([adversarialArgument]);
|
|
60
|
-
expect(JSON.parse(JSON.stringify(compiled.variables))).toEqual(
|
|
61
|
-
compiled.variables,
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("nested arguments keep null, omit undefined, and let server defaults apply", () => {
|
|
66
|
-
const compiled = G.compile(fixtureModel, "query", {
|
|
67
|
-
project: {
|
|
68
|
-
where: { id: "p1" },
|
|
69
|
-
select: {
|
|
70
|
-
services: {
|
|
71
|
-
where: { first: undefined, after: null },
|
|
72
|
-
select: { edges: { node: { id: true } } },
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
expect(compiled.query).not.toContain("first:");
|
|
78
|
-
expect(compiled.query).toMatch(/services\s*\(\s*after\s*:\s*\$/);
|
|
79
|
-
expect(Object.values(compiled.variables)).toEqual(["p1", null]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("input objects preserve explicit null and omit optional members", () => {
|
|
83
|
-
const compiled = G.compile(fixtureModel, "query", {
|
|
84
|
-
projects: {
|
|
85
|
-
where: {
|
|
86
|
-
filter: {
|
|
87
|
-
name: null,
|
|
88
|
-
statuses: undefined,
|
|
89
|
-
metadata: { text: adversarialArgument, absent: undefined },
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
select: { id: true },
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
expect(
|
|
96
|
-
JSON.parse(JSON.stringify(Object.values(compiled.variables)[0])),
|
|
97
|
-
).toEqual({ name: null, metadata: { text: adversarialArgument } });
|
|
98
|
-
expect(compiled.query).toContain("ProjectFilter");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("aliases give repeated roots independent variables", () => {
|
|
102
|
-
const compiled = G.compile(fixtureModel, "query", {
|
|
103
|
-
__alias: {
|
|
104
|
-
production: { project: { where: { id: "p1" }, select: { id: true } } },
|
|
105
|
-
staging: { project: { where: { id: "p2" }, select: { name: true } } },
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
expect(compiled.query).toMatch(/production\s*:\s*project/);
|
|
109
|
-
expect(compiled.query).toMatch(/staging\s*:\s*project/);
|
|
110
|
-
expect(Object.values(compiled.variables)).toEqual(["p1", "p2"]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("supports finite selections through recursive schema relationships", () => {
|
|
114
|
-
const compiled = G.compile(fixtureModel, "query", {
|
|
115
|
-
project: {
|
|
116
|
-
where: { id: "p1" },
|
|
117
|
-
select: { parent: { parent: { parent: { name: true } } } },
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
expect(compiled.query.match(/parent/g)).toHaveLength(3);
|
|
121
|
-
expect(compiled.query).not.toContain("services");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test("rejects invalid selections before transport execution", () => {
|
|
125
|
-
expect(() =>
|
|
126
|
-
G.compile(fixtureModel, "query", {
|
|
127
|
-
project: { where: { id: "p1" }, select: { unknownField: true } },
|
|
128
|
-
}),
|
|
129
|
-
).toThrow();
|
|
130
|
-
expect(() =>
|
|
131
|
-
G.compile(fixtureModel, "query", {
|
|
132
|
-
project: { where: {}, select: { id: true } },
|
|
133
|
-
}),
|
|
134
|
-
).toThrow();
|
|
135
|
-
expect(() =>
|
|
136
|
-
G.compile(fixtureModel, "query", {
|
|
137
|
-
project: { where: { id: "p1" }, select: {} },
|
|
138
|
-
}),
|
|
139
|
-
).toThrow();
|
|
140
|
-
expect(() =>
|
|
141
|
-
G.compile(fixtureModel, "query", { "bad alias": true }),
|
|
142
|
-
).toThrow();
|
|
143
|
-
});
|
|
22
|
+
const User: TypeMeta = { name: "User", fields: {} };
|
|
23
|
+
const Project: TypeMeta = { name: "Project", fields: {} };
|
|
24
|
+
Object.assign(User.fields, {
|
|
25
|
+
email: scalarField("email"),
|
|
26
|
+
name: scalarField("name"),
|
|
27
|
+
projects: listField("projects", Project, { first: "Int" }),
|
|
28
|
+
});
|
|
29
|
+
Object.assign(Project.fields, {
|
|
30
|
+
name: scalarField("name"),
|
|
31
|
+
stars: scalarField("stars"),
|
|
144
32
|
});
|
|
145
33
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
expect((await failure(client.query(invalidRoot)))._tag).toBe(
|
|
162
|
-
"GraphQLRequestError",
|
|
163
|
-
);
|
|
164
|
-
expect((await failure(client.query(invalidNested)))._tag).toBe(
|
|
165
|
-
"GraphQLRequestError",
|
|
166
|
-
);
|
|
167
|
-
expect(
|
|
168
|
-
(
|
|
169
|
-
await failure(
|
|
170
|
-
client.operation("query", "project")({ id: "p1" }, invalidProjection),
|
|
171
|
-
)
|
|
172
|
-
)._tag,
|
|
173
|
-
).toBe("GraphQLRequestError");
|
|
174
|
-
expect((await failure(client.mutation(invalidMutation)))._tag).toBe(
|
|
175
|
-
"GraphQLRequestError",
|
|
176
|
-
);
|
|
177
|
-
expect(requests).toHaveLength(0);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("sends multiple roots in one HTTP request", async () => {
|
|
181
|
-
const { client, requests } = harness({
|
|
182
|
-
data: { project: { id: "p1" }, ping: true },
|
|
183
|
-
});
|
|
184
|
-
const value = await Effect.runPromise(
|
|
185
|
-
client.query({ ...projectIdentity, ping: true }),
|
|
186
|
-
);
|
|
187
|
-
expect(value).toEqual({ project: { id: "p1" }, ping: true });
|
|
188
|
-
expect(requests).toHaveLength(1);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("__proto__ aliases are own properties without changing result prototypes", async () => {
|
|
192
|
-
const { client } = harness({ data: { ["__proto__"]: { id: "p1" } } });
|
|
193
|
-
const value = await Effect.runPromise(
|
|
194
|
-
client.query({
|
|
195
|
-
__alias: {
|
|
196
|
-
["__proto__"]: {
|
|
197
|
-
project: { where: { id: "p1" }, select: { id: true } },
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
}),
|
|
201
|
-
);
|
|
202
|
-
expect(Object.hasOwn(value, "__proto__")).toBe(true);
|
|
203
|
-
expect(Object.getPrototypeOf(value)).toBe(Object.prototype);
|
|
204
|
-
expect(value["__proto__"]).toEqual({ id: "p1" });
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("decodes null field and nullable list items without inventing missing fields", async () => {
|
|
208
|
-
const { client } = harness({
|
|
209
|
-
data: { projects: [null, { id: "p1", description: null }] },
|
|
210
|
-
});
|
|
211
|
-
const value = await Effect.runPromise(
|
|
212
|
-
client.query({ projects: { select: { id: true, description: true } } }),
|
|
213
|
-
);
|
|
214
|
-
expect(value).toEqual({
|
|
215
|
-
projects: [null, { id: "p1", description: null }],
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("decodes union fragments into the selected concrete branch", async () => {
|
|
220
|
-
const { client, requests } = harness({
|
|
221
|
-
data: {
|
|
222
|
-
search: [
|
|
223
|
-
{ __typename: "Project", name: "Production" },
|
|
224
|
-
{ __typename: "Service", status: "ACTIVE" },
|
|
225
|
-
],
|
|
226
|
-
},
|
|
227
|
-
});
|
|
228
|
-
const value = await Effect.runPromise(
|
|
229
|
-
client.query({
|
|
230
|
-
search: {
|
|
231
|
-
where: { text: "prod" },
|
|
232
|
-
select: {
|
|
233
|
-
__typename: true,
|
|
234
|
-
__on: { Project: { name: true }, Service: { status: true } },
|
|
235
|
-
},
|
|
236
|
-
},
|
|
237
|
-
}),
|
|
238
|
-
);
|
|
239
|
-
expect(value.search).toEqual([
|
|
240
|
-
{ __typename: "Project", name: "Production" },
|
|
241
|
-
{ __typename: "Service", status: "ACTIVE" },
|
|
242
|
-
]);
|
|
243
|
-
expect(requests[0]!.query).toMatch(/\.\.\.\s*on\s+Project/);
|
|
244
|
-
expect(requests[0]!.query).toMatch(/\.\.\.\s*on\s+Service/);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("interface selections include shared fields and concrete fragments", async () => {
|
|
248
|
-
const { client } = harness({
|
|
249
|
-
data: { node: { __typename: "Project", id: "p1", name: "Production" } },
|
|
250
|
-
});
|
|
251
|
-
const value = await Effect.runPromise(
|
|
252
|
-
client.query({
|
|
253
|
-
node: {
|
|
254
|
-
where: { id: "p1" },
|
|
255
|
-
select: {
|
|
256
|
-
__typename: true,
|
|
257
|
-
id: true,
|
|
258
|
-
__on: { Project: { name: true } },
|
|
259
|
-
},
|
|
260
|
-
},
|
|
261
|
-
}),
|
|
262
|
-
);
|
|
263
|
-
expect(value.node).toEqual({
|
|
264
|
-
__typename: "Project",
|
|
265
|
-
id: "p1",
|
|
266
|
-
name: "Production",
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
test("interface and concrete selections merge nested fields at the same response path", async () => {
|
|
271
|
-
const { client } = harness({
|
|
272
|
-
data: {
|
|
273
|
-
node: {
|
|
274
|
-
__typename: "Project",
|
|
275
|
-
parent: { id: "parent-1", name: "Parent" },
|
|
276
|
-
},
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
const value = await Effect.runPromise(
|
|
280
|
-
client.query({
|
|
281
|
-
node: {
|
|
282
|
-
where: { id: "p1" },
|
|
283
|
-
select: {
|
|
284
|
-
__typename: true,
|
|
285
|
-
parent: { id: true },
|
|
286
|
-
__on: { Project: { parent: { name: true } } },
|
|
34
|
+
const Mock = Layer.succeed(GqlTransport, {
|
|
35
|
+
execute: (request) =>
|
|
36
|
+
Effect.sync(() => {
|
|
37
|
+
expect(request.document).toContain("email");
|
|
38
|
+
expect(request.document).toContain("stars");
|
|
39
|
+
expect(request.document).toContain("name");
|
|
40
|
+
return {
|
|
41
|
+
data: {
|
|
42
|
+
me: {
|
|
43
|
+
email: "ada@railway.app",
|
|
44
|
+
projects: [
|
|
45
|
+
{ name: "engine", stars: 12 },
|
|
46
|
+
{ name: "bombe", stars: 3 },
|
|
47
|
+
],
|
|
287
48
|
},
|
|
288
49
|
},
|
|
289
|
-
}
|
|
290
|
-
)
|
|
291
|
-
expect(value.node).toEqual({
|
|
292
|
-
__typename: "Project",
|
|
293
|
-
parent: { id: "parent-1", name: "Parent" },
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test("include and skip directives allow omitted fields without false decode failures", async () => {
|
|
298
|
-
const { client, requests } = harness({ data: { project: { id: "p1" } } });
|
|
299
|
-
const include: boolean = false;
|
|
300
|
-
const skip: boolean = true;
|
|
301
|
-
const value = await Effect.runPromise(
|
|
302
|
-
client.query({
|
|
303
|
-
project: {
|
|
304
|
-
where: { id: "p1" },
|
|
305
|
-
select: { id: true, name: { $skip: skip, select: true } },
|
|
306
|
-
},
|
|
307
|
-
ping: { $include: include, select: true },
|
|
308
|
-
}),
|
|
309
|
-
);
|
|
310
|
-
expect(value).toEqual({ project: { id: "p1" } });
|
|
311
|
-
expect(requests[0]!.query).toContain("@include");
|
|
312
|
-
expect(requests[0]!.query).toContain("@skip");
|
|
313
|
-
expect(Object.values(requests[0]!.variables)).toContain(false);
|
|
314
|
-
expect(Object.values(requests[0]!.variables)).toContain(true);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("bad selected scalar types produce a decode error", async () => {
|
|
318
|
-
const { client } = harness({
|
|
319
|
-
data: { project: { id: { invalid: true } } },
|
|
320
|
-
});
|
|
321
|
-
expect((await failure(client.query(projectIdentity)))._tag).toBe(
|
|
322
|
-
"GraphQLDecodeError",
|
|
323
|
-
);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
test("missing non-null selected fields produce a decode error", async () => {
|
|
327
|
-
const { client } = harness({ data: { project: {} } });
|
|
328
|
-
expect((await failure(client.query(projectIdentity)))._tag).toBe(
|
|
329
|
-
"GraphQLDecodeError",
|
|
330
|
-
);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
test("malformed errors envelopes never masquerade as success", async () => {
|
|
334
|
-
const { client } = harness({
|
|
335
|
-
data: { ping: true },
|
|
336
|
-
errors: [{ path: ["ping"] }],
|
|
337
|
-
});
|
|
338
|
-
expect((await failure(client.query({ ping: true })))._tag).toBe(
|
|
339
|
-
"GraphQLDecodeError",
|
|
340
|
-
);
|
|
341
|
-
});
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
342
52
|
});
|
|
343
53
|
|
|
344
|
-
describe("
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
},
|
|
366
|
-
errors: [
|
|
367
|
-
{
|
|
368
|
-
message: "Description unavailable",
|
|
369
|
-
path: ["search", 0, "label"],
|
|
370
|
-
extensions: { code: "DESCRIPTION_UNAVAILABLE" },
|
|
371
|
-
},
|
|
372
|
-
{
|
|
373
|
-
message: "Description unavailable",
|
|
374
|
-
path: ["search", 1, "label"],
|
|
375
|
-
extensions: { code: "DESCRIPTION_UNAVAILABLE" },
|
|
376
|
-
},
|
|
377
|
-
],
|
|
378
|
-
});
|
|
379
|
-
const report = await Effect.runPromise(
|
|
380
|
-
client.report.query(descriptionSelection),
|
|
381
|
-
);
|
|
382
|
-
expect(report.errors.map((error) => error._tag)).toEqual([
|
|
383
|
-
"ServiceDescriptionUnavailable",
|
|
384
|
-
"ProjectDescriptionUnavailable",
|
|
385
|
-
]);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
test("erased concrete-type data leaves conflicting equal-rank error matches unknown", async () => {
|
|
389
|
-
const { client } = harness({
|
|
390
|
-
data: null,
|
|
391
|
-
errors: [
|
|
392
|
-
{
|
|
393
|
-
message: "Description unavailable",
|
|
394
|
-
path: ["search", 0, "label"],
|
|
395
|
-
extensions: { code: "DESCRIPTION_UNAVAILABLE" },
|
|
396
|
-
},
|
|
397
|
-
],
|
|
398
|
-
});
|
|
399
|
-
const report = await Effect.runPromise(
|
|
400
|
-
client.report.query(descriptionSelection),
|
|
401
|
-
);
|
|
402
|
-
expect(report.errors[0]!._tag).toBe("UnknownGraphQLError");
|
|
403
|
-
expect(report.errors[0]!.path).toEqual(["search", 0, "label"]);
|
|
404
|
-
});
|
|
405
|
-
test("strict execution aggregates a single field issue and preserves diagnostics", async () => {
|
|
406
|
-
const { client } = harness(partialProjectEnvelope);
|
|
407
|
-
const error = await failure(
|
|
408
|
-
client.query({ ...servicesSelection, ping: true }),
|
|
409
|
-
);
|
|
410
|
-
expect(error._tag).toBe("GraphQLFailure");
|
|
411
|
-
if (error._tag !== "GraphQLFailure")
|
|
412
|
-
throw new Error("Expected GraphQLFailure");
|
|
413
|
-
expect(error.errors).toHaveLength(1);
|
|
414
|
-
expect(error.errors[0]!._tag).toBe("ServicesUnavailable");
|
|
415
|
-
expect(error.errors[0]!.path).toEqual(["project", "services"]);
|
|
416
|
-
expect(error.errors[0]!.extensions).toEqual({
|
|
417
|
-
code: "UNAVAILABLE",
|
|
418
|
-
incident: "incident-1",
|
|
54
|
+
describe("Query.fn", () => {
|
|
55
|
+
test("builds one document from a returned plan and maps/filters after fetch", async () => {
|
|
56
|
+
const program = Query.fn(() => {
|
|
57
|
+
const me = root<UserRow>("query", "me", User);
|
|
58
|
+
return {
|
|
59
|
+
email: me.email,
|
|
60
|
+
projects: me.projects({ first: 5 }).pipe(
|
|
61
|
+
Query.filter((project: Query<{ stars: number; name: string }>) =>
|
|
62
|
+
project.stars.pipe(Query.map((stars: number) => stars > 5)),
|
|
63
|
+
),
|
|
64
|
+
Query.map((project: Query<{ stars: number; name: string }>) => ({
|
|
65
|
+
name: project.name,
|
|
66
|
+
stars: project.stars,
|
|
67
|
+
})),
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
})();
|
|
71
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(Mock)));
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
email: "ada@railway.app",
|
|
74
|
+
projects: [{ name: "engine", stars: 12 }],
|
|
419
75
|
});
|
|
420
|
-
expect(error.data).toEqual(partialProjectEnvelope.data);
|
|
421
76
|
});
|
|
422
77
|
|
|
423
|
-
test("
|
|
424
|
-
const {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
},
|
|
442
|
-
staging: {
|
|
443
|
-
project: {
|
|
444
|
-
where: { id: "p2" },
|
|
445
|
-
select: servicesSelection.project.select,
|
|
78
|
+
test("Relay connections map as a list of nodes, one POST", async () => {
|
|
79
|
+
const Workspace: TypeMeta = { name: "Workspace", fields: {} };
|
|
80
|
+
Object.assign(Workspace.fields, { name: scalarField("name") });
|
|
81
|
+
const layer = Layer.succeed(GqlTransport, {
|
|
82
|
+
execute: (request) =>
|
|
83
|
+
Effect.sync(() => {
|
|
84
|
+
expect(request.document).toContain("edges");
|
|
85
|
+
expect(request.document).toContain("node");
|
|
86
|
+
expect(request.document).toContain("name");
|
|
87
|
+
expect(request.document).not.toContain("pageInfo");
|
|
88
|
+
return {
|
|
89
|
+
data: {
|
|
90
|
+
projects: {
|
|
91
|
+
edges: [
|
|
92
|
+
{ node: { name: "engine" } },
|
|
93
|
+
{ node: { name: "bombe" } },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
446
96
|
},
|
|
447
|
-
}
|
|
448
|
-
},
|
|
449
|
-
}),
|
|
450
|
-
);
|
|
451
|
-
expect(error._tag).toBe("GraphQLFailure");
|
|
452
|
-
if (error._tag !== "GraphQLFailure")
|
|
453
|
-
throw new Error("Expected GraphQLFailure");
|
|
454
|
-
expect(error.errors.map((issue) => issue._tag)).toEqual([
|
|
455
|
-
"ProjectNotFound",
|
|
456
|
-
"ServicesUnavailable",
|
|
457
|
-
]);
|
|
458
|
-
expect(error.errors.map((issue) => issue.path)).toEqual([
|
|
459
|
-
["production"],
|
|
460
|
-
["staging", "services"],
|
|
461
|
-
]);
|
|
462
|
-
expect(error.data).toEqual(multipleErrorEnvelope.data);
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
test("null bubbling and list indexes do not prevent schema-coordinate matching", async () => {
|
|
466
|
-
const { client } = harness({
|
|
467
|
-
data: { projects: [null] },
|
|
468
|
-
errors: [
|
|
469
|
-
{
|
|
470
|
-
message: "Services unavailable",
|
|
471
|
-
path: ["projects", 0, "services"],
|
|
472
|
-
extensions: { code: "UNAVAILABLE" },
|
|
473
|
-
},
|
|
474
|
-
],
|
|
475
|
-
});
|
|
476
|
-
const report = await Effect.runPromise(
|
|
477
|
-
client.report.query({
|
|
478
|
-
projects: { select: servicesSelection.project.select },
|
|
479
|
-
}),
|
|
480
|
-
);
|
|
481
|
-
expect(report.errors[0]!._tag).toBe("ServicesUnavailable");
|
|
482
|
-
expect(report.errors[0]!.path).toEqual(["projects", 0, "services"]);
|
|
483
|
-
expect(report.data).toEqual({ projects: [null] });
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
test("overlapping interface and concrete selections retain nested error contracts", async () => {
|
|
487
|
-
const { client } = harness({
|
|
488
|
-
data: {
|
|
489
|
-
node: {
|
|
490
|
-
__typename: "Project",
|
|
491
|
-
parent: { id: "parent-1", services: null },
|
|
492
|
-
},
|
|
493
|
-
},
|
|
494
|
-
errors: [
|
|
495
|
-
{
|
|
496
|
-
message: "Services unavailable",
|
|
497
|
-
path: ["node", "parent", "services"],
|
|
498
|
-
extensions: { code: "UNAVAILABLE" },
|
|
499
|
-
},
|
|
500
|
-
],
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
501
99
|
});
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
100
|
+
const program = Query.fn(() => {
|
|
101
|
+
const page = rootConnection<{ name: string }>(
|
|
102
|
+
"query",
|
|
103
|
+
"projects",
|
|
104
|
+
Workspace,
|
|
105
|
+
{ first: 20 },
|
|
106
|
+
{ first: "Int" },
|
|
107
|
+
);
|
|
108
|
+
return page.pipe(Query.map((project) => project.name));
|
|
109
|
+
})();
|
|
110
|
+
const names = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
111
|
+
expect(names).toEqual(["engine", "bombe"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("nested connection fields unwrap edges.node", async () => {
|
|
115
|
+
const Service: TypeMeta = { name: "Service", fields: {} };
|
|
116
|
+
Object.assign(Service.fields, { name: scalarField("name") });
|
|
117
|
+
const Project: TypeMeta = { name: "Project", fields: {} };
|
|
118
|
+
Object.assign(Project.fields, {
|
|
119
|
+
name: scalarField("name"),
|
|
120
|
+
services: connectionField("services", Service, { first: "Int" }),
|
|
121
|
+
});
|
|
122
|
+
const layer = Layer.succeed(GqlTransport, {
|
|
123
|
+
execute: (request) =>
|
|
124
|
+
Effect.sync(() => {
|
|
125
|
+
expect(request.document).toMatch(/services[\s\S]*edges[\s\S]*node/);
|
|
126
|
+
return {
|
|
127
|
+
data: {
|
|
128
|
+
project: {
|
|
129
|
+
name: "engine",
|
|
130
|
+
services: {
|
|
131
|
+
edges: [{ node: { name: "web" } }, { node: { name: "db" } }],
|
|
513
132
|
},
|
|
514
133
|
},
|
|
515
134
|
},
|
|
516
|
-
}
|
|
517
|
-
},
|
|
518
|
-
}),
|
|
519
|
-
);
|
|
520
|
-
expect(report.errors[0]!._tag).toBe("ServicesUnavailable");
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
test("known codes at undeclared fields fall back to unknown", async () => {
|
|
524
|
-
const { client } = harness({
|
|
525
|
-
data: { project: null },
|
|
526
|
-
errors: [
|
|
527
|
-
{
|
|
528
|
-
message: "Unavailable",
|
|
529
|
-
path: ["project"],
|
|
530
|
-
extensions: { code: "UNAVAILABLE" },
|
|
531
|
-
},
|
|
532
|
-
],
|
|
533
|
-
});
|
|
534
|
-
const report = await Effect.runPromise(
|
|
535
|
-
client.report.query(projectIdentity),
|
|
536
|
-
);
|
|
537
|
-
expect(report.errors[0]!._tag).toBe("UnknownGraphQLError");
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
test("unrecognized and unselected error paths fall back to unknown", async () => {
|
|
541
|
-
const { client } = harness({
|
|
542
|
-
data: { project: null },
|
|
543
|
-
errors: [
|
|
544
|
-
{
|
|
545
|
-
message: "New provider error",
|
|
546
|
-
path: ["project"],
|
|
547
|
-
extensions: { code: "NEW_CODE" },
|
|
548
|
-
},
|
|
549
|
-
{
|
|
550
|
-
message: "Unknown field path",
|
|
551
|
-
path: ["neverSelected"],
|
|
552
|
-
extensions: { code: "NOT_FOUND" },
|
|
553
|
-
},
|
|
554
|
-
],
|
|
555
|
-
});
|
|
556
|
-
const report = await Effect.runPromise(
|
|
557
|
-
client.report.query(projectIdentity),
|
|
558
|
-
);
|
|
559
|
-
expect(report.errors.map((error) => error._tag)).toEqual([
|
|
560
|
-
"UnknownGraphQLError",
|
|
561
|
-
"UnknownGraphQLError",
|
|
562
|
-
]);
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
test("global request errors without paths are decoded even on HTTP 400", async () => {
|
|
566
|
-
const { client } = harness(
|
|
567
|
-
{
|
|
568
|
-
errors: [
|
|
569
|
-
{
|
|
570
|
-
message: "Authentication required",
|
|
571
|
-
extensions: { code: "UNAUTHENTICATED" },
|
|
572
|
-
},
|
|
573
|
-
],
|
|
574
|
-
},
|
|
575
|
-
400,
|
|
576
|
-
);
|
|
577
|
-
const report = await Effect.runPromise(
|
|
578
|
-
client.report.query(projectIdentity),
|
|
579
|
-
);
|
|
580
|
-
expect(report.errors[0]!._tag).toBe("Unauthorized");
|
|
581
|
-
expect(report.status).toBe(400);
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
test("code and message matchers require both constraints", async () => {
|
|
585
|
-
const { client } = harness({
|
|
586
|
-
errors: [
|
|
587
|
-
{
|
|
588
|
-
message: "Not Authorized",
|
|
589
|
-
extensions: { code: "INTERNAL_SERVER_ERROR" },
|
|
590
|
-
},
|
|
591
|
-
{
|
|
592
|
-
message: "Resolver crashed",
|
|
593
|
-
extensions: { code: "INTERNAL_SERVER_ERROR" },
|
|
594
|
-
},
|
|
595
|
-
],
|
|
596
|
-
});
|
|
597
|
-
const report = await Effect.runPromise(
|
|
598
|
-
client.report.query(projectIdentity),
|
|
599
|
-
);
|
|
600
|
-
expect(report.errors.map((error) => error._tag)).toEqual([
|
|
601
|
-
"Unauthorized",
|
|
602
|
-
"UnknownGraphQLError",
|
|
603
|
-
]);
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
test("a mutation with response errors is never automatically retried", async () => {
|
|
607
|
-
const { client, requests } = harness({
|
|
608
|
-
data: null,
|
|
609
|
-
errors: [
|
|
610
|
-
{
|
|
611
|
-
message: "Not found",
|
|
612
|
-
path: ["editProject"],
|
|
613
|
-
extensions: { code: "NOT_FOUND" },
|
|
614
|
-
},
|
|
615
|
-
],
|
|
616
|
-
});
|
|
617
|
-
const error = await failure(
|
|
618
|
-
client.mutation({
|
|
619
|
-
editProject: {
|
|
620
|
-
where: { id: "p1", name: "Changed" },
|
|
621
|
-
select: { id: true },
|
|
622
|
-
},
|
|
623
|
-
}),
|
|
624
|
-
);
|
|
625
|
-
expect(error._tag).toBe("GraphQLFailure");
|
|
626
|
-
expect(requests).toHaveLength(1);
|
|
627
|
-
expect(requests[0]!.query).toMatch(/^mutation/);
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
test("catchTags recovers only when every issue has an allowed tag", async () => {
|
|
631
|
-
const { client } = harness({
|
|
632
|
-
data: { project: null },
|
|
633
|
-
errors: [
|
|
634
|
-
{
|
|
635
|
-
message: "Gone",
|
|
636
|
-
path: ["project"],
|
|
637
|
-
extensions: { code: "NOT_FOUND" },
|
|
638
|
-
},
|
|
639
|
-
],
|
|
640
|
-
});
|
|
641
|
-
const value = await Effect.runPromise(
|
|
642
|
-
client
|
|
643
|
-
.query(projectIdentity)
|
|
644
|
-
.pipe(G.catchTags("ProjectNotFound", () => Effect.succeed("absent"))),
|
|
645
|
-
);
|
|
646
|
-
expect(value).toBe("absent");
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
test("catchTags never hides another issue in a mixed aggregate", async () => {
|
|
650
|
-
const { client } = harness({
|
|
651
|
-
data: { project: null },
|
|
652
|
-
errors: [
|
|
653
|
-
{
|
|
654
|
-
message: "Gone",
|
|
655
|
-
path: ["project"],
|
|
656
|
-
extensions: { code: "NOT_FOUND" },
|
|
657
|
-
},
|
|
658
|
-
{
|
|
659
|
-
message: "Authentication required",
|
|
660
|
-
extensions: { code: "UNAUTHENTICATED" },
|
|
661
|
-
},
|
|
662
|
-
],
|
|
135
|
+
};
|
|
136
|
+
}),
|
|
663
137
|
});
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
138
|
+
const program = Query.fn(() => {
|
|
139
|
+
const project = root<{
|
|
140
|
+
name: string;
|
|
141
|
+
services: ReadonlyArray<{ name: string }>;
|
|
142
|
+
}>("query", "project", Project, { id: "p1" }, { id: "String!" });
|
|
143
|
+
return {
|
|
144
|
+
name: project.name,
|
|
145
|
+
services: project
|
|
146
|
+
.services({ first: 20 })
|
|
147
|
+
.pipe(Query.map((service: Query<{ name: string }>) => service.name)),
|
|
148
|
+
};
|
|
149
|
+
})();
|
|
150
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
151
|
+
expect(result).toEqual({ name: "engine", services: ["web", "db"] });
|
|
152
|
+
});
|
|
153
|
+
test("Query.map on an object selects its fields and keeps null", async () => {
|
|
154
|
+
const Deployment: TypeMeta = { name: "Deployment", fields: {} };
|
|
155
|
+
Object.assign(Deployment.fields, {
|
|
156
|
+
id: scalarField("id"),
|
|
157
|
+
status: scalarField("status"),
|
|
158
|
+
});
|
|
159
|
+
const Service: TypeMeta = { name: "Service", fields: {} };
|
|
160
|
+
Object.assign(Service.fields, {
|
|
161
|
+
name: scalarField("name"),
|
|
162
|
+
latestDeployment: objectField("latestDeployment", Deployment),
|
|
163
|
+
});
|
|
164
|
+
const documents: string[] = [];
|
|
165
|
+
const respond = (latestDeployment: unknown) =>
|
|
166
|
+
Layer.succeed(GqlTransport, {
|
|
167
|
+
execute: (request) =>
|
|
668
168
|
Effect.sync(() => {
|
|
669
|
-
|
|
670
|
-
return
|
|
169
|
+
documents.push(request.document);
|
|
170
|
+
return {
|
|
171
|
+
data: { service: { name: "web", latestDeployment } },
|
|
172
|
+
};
|
|
671
173
|
}),
|
|
174
|
+
});
|
|
175
|
+
const program = Query.fn(() => {
|
|
176
|
+
const service = root<{
|
|
177
|
+
name: string;
|
|
178
|
+
latestDeployment: { id: string; status: string } | null;
|
|
179
|
+
}>("query", "service", Service);
|
|
180
|
+
return {
|
|
181
|
+
name: service.name,
|
|
182
|
+
deployment: service.latestDeployment.pipe(
|
|
183
|
+
Query.map((deployment) => ({
|
|
184
|
+
id: deployment.id,
|
|
185
|
+
status: deployment.status,
|
|
186
|
+
})),
|
|
672
187
|
),
|
|
188
|
+
};
|
|
189
|
+
})();
|
|
190
|
+
expect(
|
|
191
|
+
await Effect.runPromise(
|
|
192
|
+
program.pipe(Effect.provide(respond({ id: "d1", status: "SUCCESS" }))),
|
|
673
193
|
),
|
|
674
|
-
);
|
|
675
|
-
expect(
|
|
676
|
-
expect(
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
expect(error.errors.map((issue) => issue._tag)).toEqual([
|
|
680
|
-
"ProjectNotFound",
|
|
681
|
-
"Unauthorized",
|
|
682
|
-
]);
|
|
683
|
-
});
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
describe("GraphQL connection pagination", () => {
|
|
687
|
-
const paginated = (
|
|
688
|
-
responses: Array<{
|
|
689
|
-
edges: Array<{ node: { id: string } }>;
|
|
690
|
-
pageInfo: { endCursor: string | null; hasNextPage: boolean };
|
|
691
|
-
}>,
|
|
692
|
-
) => {
|
|
693
|
-
const requests: Array<G.GraphQLRequest> = [];
|
|
694
|
-
const client = G.makeClient<FixtureSchema, never>(
|
|
695
|
-
fixtureModel,
|
|
696
|
-
(request) =>
|
|
697
|
-
Effect.sync(() => {
|
|
698
|
-
const page = responses[requests.length];
|
|
699
|
-
requests.push(request);
|
|
700
|
-
if (!page)
|
|
701
|
-
throw new Error("Paginator made an unexpected extra request");
|
|
702
|
-
// Respect response aliases, including cursor fields added by the client.
|
|
703
|
-
const document = parse(request.query);
|
|
704
|
-
const operation = document.definitions.find(
|
|
705
|
-
(node) => node.kind === Kind.OPERATION_DEFINITION,
|
|
706
|
-
)!;
|
|
707
|
-
if (operation.kind !== Kind.OPERATION_DEFINITION)
|
|
708
|
-
throw new Error("Expected an operation");
|
|
709
|
-
const root = operation.selectionSet.selections[0]!;
|
|
710
|
-
if (root.kind !== Kind.FIELD)
|
|
711
|
-
throw new Error("Expected a connection field");
|
|
712
|
-
const selected: Record<string, unknown> = {};
|
|
713
|
-
for (const child of root.selectionSet!.selections) {
|
|
714
|
-
if (child.kind !== Kind.FIELD) continue;
|
|
715
|
-
selected[child.alias?.value ?? child.name.value] =
|
|
716
|
-
page[child.name.value as keyof typeof page];
|
|
717
|
-
}
|
|
718
|
-
return {
|
|
719
|
-
body: { data: { services: selected } },
|
|
720
|
-
status: 200,
|
|
721
|
-
headers: {},
|
|
722
|
-
};
|
|
723
|
-
}),
|
|
724
|
-
errorClasses,
|
|
725
|
-
);
|
|
726
|
-
return { requests, services: client.operation("query", "services") };
|
|
727
|
-
};
|
|
728
|
-
|
|
729
|
-
test("items advances cursors and stops on hasNextPage=false", async () => {
|
|
730
|
-
const { services, requests } = paginated([
|
|
731
|
-
{
|
|
732
|
-
edges: [{ node: { id: "s1" } }],
|
|
733
|
-
pageInfo: { endCursor: "c1", hasNextPage: true },
|
|
734
|
-
},
|
|
735
|
-
{
|
|
736
|
-
edges: [{ node: { id: "s2" } }],
|
|
737
|
-
pageInfo: { endCursor: "c2", hasNextPage: false },
|
|
738
|
-
},
|
|
739
|
-
]);
|
|
740
|
-
const values = await Effect.runPromise(
|
|
741
|
-
Stream.runCollect(services.items({ first: 1 }, { id: true })),
|
|
742
|
-
);
|
|
743
|
-
expect(Array.from(values)).toEqual([{ id: "s1" }, { id: "s2" }]);
|
|
744
|
-
expect(requests).toHaveLength(2);
|
|
745
|
-
expect(Object.values(requests[0]!.variables)).toEqual([1]);
|
|
746
|
-
expect(Object.values(requests[1]!.variables)).toEqual([1, "c1"]);
|
|
747
|
-
expect(requests[0]!.query).not.toContain("name");
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
test("empty terminal pages do not cause another request", async () => {
|
|
751
|
-
const { services, requests } = paginated([
|
|
752
|
-
{ edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
|
|
753
|
-
]);
|
|
754
|
-
const values = await Effect.runPromise(
|
|
755
|
-
Stream.runCollect(services.items({}, { id: true })),
|
|
756
|
-
);
|
|
757
|
-
expect(Array.from(values)).toEqual([]);
|
|
758
|
-
expect(requests).toHaveLength(1);
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
test("pages omits internally selected pageInfo from the requested projection", async () => {
|
|
762
|
-
const { services, requests } = paginated([
|
|
763
|
-
{
|
|
764
|
-
edges: [{ node: { id: "s1" } }],
|
|
765
|
-
pageInfo: { endCursor: null, hasNextPage: false },
|
|
766
|
-
},
|
|
767
|
-
]);
|
|
768
|
-
const pages = await Effect.runPromise(
|
|
769
|
-
Stream.runCollect(services.pages({}, { edges: { node: { id: true } } })),
|
|
770
|
-
);
|
|
771
|
-
expect(Array.from(pages)).toEqual([{ edges: [{ node: { id: "s1" } }] }]);
|
|
772
|
-
expect(requests[0]!.query).toContain("pageInfo");
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
test("a user alias named pageInfo cannot collide with hidden pagination fields", async () => {
|
|
776
|
-
const { services, requests } = paginated([
|
|
777
|
-
{
|
|
778
|
-
edges: [{ node: { id: "s1" } }],
|
|
779
|
-
pageInfo: { endCursor: null, hasNextPage: false },
|
|
780
|
-
},
|
|
781
|
-
]);
|
|
782
|
-
const pages = await Effect.runPromise(
|
|
783
|
-
Stream.runCollect(
|
|
784
|
-
services.pages(
|
|
785
|
-
{},
|
|
786
|
-
{
|
|
787
|
-
__alias: { pageInfo: { edges: { node: { id: true } } } },
|
|
788
|
-
},
|
|
789
|
-
),
|
|
790
|
-
),
|
|
791
|
-
);
|
|
792
|
-
expect(Array.from(pages)).toEqual([{ pageInfo: [{ node: { id: "s1" } }] }]);
|
|
793
|
-
expect(requests).toHaveLength(1);
|
|
794
|
-
});
|
|
795
|
-
|
|
796
|
-
test("wrapped pageInfo selections still fetch the internal pagination cursor", async () => {
|
|
797
|
-
const { services, requests } = paginated([
|
|
798
|
-
{ edges: [], pageInfo: { endCursor: "c1", hasNextPage: true } },
|
|
799
|
-
{ edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
|
|
800
|
-
]);
|
|
801
|
-
const pages = await Effect.runPromise(
|
|
802
|
-
Stream.runCollect(
|
|
803
|
-
services.pages({}, { pageInfo: { select: { hasNextPage: true } } }),
|
|
804
|
-
),
|
|
805
|
-
);
|
|
806
|
-
expect(Array.from(pages)).toEqual([
|
|
807
|
-
{ pageInfo: { hasNextPage: true } },
|
|
808
|
-
{ pageInfo: { hasNextPage: false } },
|
|
809
|
-
]);
|
|
810
|
-
expect(requests).toHaveLength(2);
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
test("conditional pageInfo projection cannot skip internal cursor fetching", async () => {
|
|
814
|
-
const { services, requests } = paginated([
|
|
815
|
-
{
|
|
816
|
-
edges: [{ node: { id: "s1" } }],
|
|
817
|
-
pageInfo: { endCursor: "c1", hasNextPage: true },
|
|
818
|
-
},
|
|
819
|
-
{
|
|
820
|
-
edges: [{ node: { id: "s2" } }],
|
|
821
|
-
pageInfo: { endCursor: null, hasNextPage: false },
|
|
822
|
-
},
|
|
823
|
-
]);
|
|
824
|
-
const pages = await Effect.runPromise(
|
|
825
|
-
Stream.runCollect(
|
|
826
|
-
services.pages(
|
|
827
|
-
{},
|
|
828
|
-
{
|
|
829
|
-
edges: { node: { id: true } },
|
|
830
|
-
pageInfo: { $skip: true, select: { hasNextPage: true } },
|
|
831
|
-
},
|
|
832
|
-
),
|
|
833
|
-
),
|
|
834
|
-
);
|
|
835
|
-
expect(Array.from(pages)).toEqual([
|
|
836
|
-
{ edges: [{ node: { id: "s1" } }] },
|
|
837
|
-
{ edges: [{ node: { id: "s2" } }] },
|
|
838
|
-
]);
|
|
839
|
-
expect(requests).toHaveLength(2);
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
test("non-advancing cursors fail without hanging", async () => {
|
|
843
|
-
const { services, requests } = paginated([
|
|
844
|
-
{
|
|
845
|
-
edges: [{ node: { id: "s1" } }],
|
|
846
|
-
pageInfo: { endCursor: "c1", hasNextPage: true },
|
|
847
|
-
},
|
|
848
|
-
{
|
|
849
|
-
edges: [{ node: { id: "s1" } }],
|
|
850
|
-
pageInfo: { endCursor: "c1", hasNextPage: true },
|
|
851
|
-
},
|
|
852
|
-
]);
|
|
853
|
-
const error = await failure(
|
|
854
|
-
Stream.runCollect(services.items({}, { id: true })),
|
|
855
|
-
);
|
|
856
|
-
expect(error._tag).toBe("GraphQLDecodeError");
|
|
857
|
-
expect(requests).toHaveLength(2);
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
test("invalid pagination input fails in the typed channel without making a request", async () => {
|
|
861
|
-
const { services, requests } = paginated([]);
|
|
862
|
-
const stream = services.items(
|
|
863
|
-
{ first: "bad" as unknown as number },
|
|
864
|
-
{ id: true },
|
|
865
|
-
);
|
|
866
|
-
const error = await failure(Stream.runCollect(stream));
|
|
867
|
-
expect(error._tag).toBe("GraphQLRequestError");
|
|
868
|
-
expect(requests).toHaveLength(0);
|
|
869
|
-
});
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
describe("GraphQL safe query retries", () => {
|
|
873
|
-
const retryableModel = {
|
|
874
|
-
...fixtureModel,
|
|
875
|
-
errors: {
|
|
876
|
-
...fixtureModel.errors,
|
|
877
|
-
ServicesUnavailable: {
|
|
878
|
-
...fixtureModel.errors.ServicesUnavailable!,
|
|
879
|
-
retryable: true,
|
|
880
|
-
},
|
|
881
|
-
},
|
|
882
|
-
};
|
|
883
|
-
const unavailable = (root = "services") => ({
|
|
884
|
-
data: null,
|
|
885
|
-
errors: [
|
|
886
|
-
{
|
|
887
|
-
message: "Services unavailable",
|
|
888
|
-
path: [root],
|
|
889
|
-
extensions: { code: "UNAVAILABLE" },
|
|
890
|
-
},
|
|
891
|
-
],
|
|
892
|
-
});
|
|
893
|
-
const retried = (response: (attempt: number) => unknown) => {
|
|
894
|
-
const requests: G.GraphQLRequest[] = [];
|
|
895
|
-
const client = G.makeClient<FixtureSchema, never>(
|
|
896
|
-
retryableModel,
|
|
897
|
-
(request) =>
|
|
898
|
-
Effect.sync(() => {
|
|
899
|
-
requests.push(request);
|
|
900
|
-
return { body: response(requests.length), status: 200, headers: {} };
|
|
901
|
-
}),
|
|
902
|
-
errorClasses,
|
|
903
|
-
);
|
|
904
|
-
return { client, requests };
|
|
905
|
-
};
|
|
906
|
-
|
|
907
|
-
test("a safely retryable query reuses the document and variables", async () => {
|
|
908
|
-
const { client, requests } = retried((attempt) =>
|
|
909
|
-
attempt === 1 ? unavailable() : { data: { services: { edges: [] } } },
|
|
910
|
-
);
|
|
911
|
-
const result = await Effect.runPromise(
|
|
912
|
-
client.query({
|
|
913
|
-
services: {
|
|
914
|
-
where: { first: 1 },
|
|
915
|
-
select: { edges: { node: { id: true } } },
|
|
916
|
-
},
|
|
917
|
-
}),
|
|
918
|
-
);
|
|
919
|
-
expect(result).toEqual({ services: { edges: [] } });
|
|
920
|
-
expect(requests).toHaveLength(2);
|
|
921
|
-
expect(requests[0]!.query).toBe(requests[1]!.query);
|
|
922
|
-
expect(requests[0]!.variables).toEqual(requests[1]!.variables);
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
test("retryable query failures stop after five retries", async () => {
|
|
926
|
-
const { client, requests } = retried(() => unavailable());
|
|
927
|
-
const error = await failure(
|
|
928
|
-
client.query({ services: { select: { edges: { node: { id: true } } } } }),
|
|
929
|
-
);
|
|
930
|
-
expect(error._tag).toBe("GraphQLFailure");
|
|
931
|
-
expect(requests).toHaveLength(6);
|
|
932
|
-
}, 15_000);
|
|
933
|
-
|
|
934
|
-
test("an aggregate containing a nonretryable issue is never retried", async () => {
|
|
935
|
-
const { client, requests } = retried(() => ({
|
|
936
|
-
data: null,
|
|
937
|
-
errors: [
|
|
938
|
-
...unavailable().errors,
|
|
939
|
-
{ message: "Unauthorized", extensions: { code: "UNAUTHENTICATED" } },
|
|
940
|
-
],
|
|
941
|
-
}));
|
|
942
|
-
const error = await failure(
|
|
943
|
-
client.query({ services: { select: { edges: { node: { id: true } } } } }),
|
|
944
|
-
);
|
|
945
|
-
expect(error._tag).toBe("GraphQLFailure");
|
|
946
|
-
expect(requests).toHaveLength(1);
|
|
947
|
-
});
|
|
948
|
-
|
|
949
|
-
test("mutations never retry even when the field failure is marked retryable", async () => {
|
|
950
|
-
const { client, requests } = retried(() => ({
|
|
951
|
-
data: null,
|
|
952
|
-
errors: [
|
|
953
|
-
{
|
|
954
|
-
message: "Services unavailable",
|
|
955
|
-
path: ["editProject", "services"],
|
|
956
|
-
extensions: { code: "UNAVAILABLE" },
|
|
957
|
-
},
|
|
958
|
-
],
|
|
959
|
-
}));
|
|
960
|
-
const error = await failure(
|
|
961
|
-
client.mutation({
|
|
962
|
-
editProject: {
|
|
963
|
-
where: { id: "p1", name: "Updated" },
|
|
964
|
-
select: { services: { select: { edges: { node: { id: true } } } } },
|
|
965
|
-
},
|
|
966
|
-
}),
|
|
967
|
-
);
|
|
968
|
-
expect(error._tag).toBe("GraphQLFailure");
|
|
969
|
-
if (error._tag !== "GraphQLFailure")
|
|
970
|
-
throw new Error("Expected GraphQLFailure");
|
|
971
|
-
expect(error.errors[0]!._tag).toBe("ServicesUnavailable");
|
|
972
|
-
expect(requests).toHaveLength(1);
|
|
194
|
+
).toEqual({ name: "web", deployment: { id: "d1", status: "SUCCESS" } });
|
|
195
|
+
expect(documents[0]).toMatch(/latestDeployment \{\s+id\s+status/);
|
|
196
|
+
expect(
|
|
197
|
+
await Effect.runPromise(program.pipe(Effect.provide(respond(null)))),
|
|
198
|
+
).toEqual({ name: "web", deployment: null });
|
|
973
199
|
});
|
|
974
200
|
});
|