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