/
RelevantRunSpecsDataSource.ts
281 lines (230 loc) · 8.28 KB
/
RelevantRunSpecsDataSource.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
import { gql, TypedDocumentNode } from '@urql/core'
import { GraphQLOutputType, GraphQLResolveInfo, print, visit } from 'graphql'
import debugLib from 'debug'
import type { DataContext } from '../DataContext'
import type { Query, CloudRun } from '../gen/graphcache-config.gen'
import { Poller } from '../polling'
import { compact, isEqual, uniq } from 'lodash'
const debug = debugLib('cypress:data-context:sources:RelevantRunSpecsDataSource')
type PartialCloudRunWithId = Partial<CloudRun> & Pick<CloudRun, 'id'>
//Not ideal typing for this return since the query is not fetching all the fields, but better than nothing
export type RelevantRunSpecsCloudResult = {
cloudNodesByIds: Array<PartialCloudRunWithId>
} & Pick<Query, 'pollingIntervals'>
/**
* DataSource used to watch RUNNING CloudRuns for changes to provide
* near real time updates to the app front end
*
* This DataSource backs the `relevantRunSpecChange` subscription by creating
* a poller that will poll for changes for the set of runs. If the data
* returned changes, it will emit a message on the subscription.
*/
export class RelevantRunSpecsDataSource {
#pollingInterval: number = 15
#cached = new Map<string, PartialCloudRunWithId>()
#query?: TypedDocumentNode<any, object>
#poller?: Poller<'relevantRunSpecChange', never, { runId: string, info: GraphQLResolveInfo }>
constructor (private ctx: DataContext) {}
specs (id: string): PartialCloudRunWithId | undefined {
return this.#cached.get(id)
}
get pollingInterval () {
return this.#pollingInterval
}
/**
* Query for the set of CloudRuns by id
* @param runIds for RUNNING CloudRuns that are being watched from the front end for changes
*/
async getRelevantRunSpecs (runIds: string[]): Promise<PartialCloudRunWithId[]> {
if (runIds.length === 0) {
return []
}
debug(`Fetching runs %o`, runIds)
const result = await this.ctx.cloud.executeRemoteGraphQL<RelevantRunSpecsCloudResult>({
fieldName: 'cloudNodesByIds',
operationDoc: this.#query!,
operation: print(this.#query!),
operationVariables: {
ids: runIds,
},
requestPolicy: 'network-only', // we never want to hit local cache for this request
})
if (result.error) {
debug(`Error when fetching relevant runs for all runs: %o: error -> %o`, runIds, result.error)
return []
}
const nodes = result.data?.cloudNodesByIds
const pollingInterval = result.data?.pollingIntervals?.runByNumber
debug(`Result returned - length: ${nodes?.length} pollingInterval: ${pollingInterval}`)
if (pollingInterval) {
this.#pollingInterval = pollingInterval
if (this.#poller) {
this.#poller.interval = this.#pollingInterval
}
}
return nodes || []
}
pollForSpecs (runId: string, info: GraphQLResolveInfo) {
debug(`pollForSpecs called`)
//TODO Get spec counts before poll starts
if (!this.#poller) {
this.#poller = new Poller(this.ctx, 'relevantRunSpecChange', this.#pollingInterval, async (subscriptions) => {
debug('subscriptions', subscriptions)
const runIds = uniq(compact(subscriptions?.map((sub) => sub.meta?.runId)))
debug('Polling for specs for runs: %o', runIds)
const query = this.createQuery(compact(subscriptions.map((sub) => sub.meta?.info)))
//debug('query', query)
this.#query = query
const runs = await this.getRelevantRunSpecs(runIds)
debug(`Run data is `, runs)
runs.forEach(async (run) => {
if (!run) {
return
}
const cachedRun = this.#cached.get(run.id)
if (!cachedRun || !isEqual(run, cachedRun)) {
debug(`Caching for id %s: %o`, run.id, run)
this.#cached.set(run.id, { ...run })
const cachedRelevantRuns = this.ctx.relevantRuns.cache
if (run.runNumber === cachedRelevantRuns.selectedRunNumber) {
const projectSlug = await this.ctx.project.projectId()
debug(`Invalidate cloudProjectBySlug ${projectSlug}`)
await this.ctx.cloud.invalidate('Query', 'cloudProjectBySlug', { slug: projectSlug })
await this.ctx.emitter.relevantRunChange(cachedRelevantRuns)
}
this.ctx.emitter.relevantRunSpecChange(run)
}
})
})
}
const filter = (run: PartialCloudRunWithId) => {
debug('calling filter', run.id, runId)
return run.id === runId
}
return this.#poller.start({ meta: { runId, info }, filter })
}
createQuery (infos: GraphQLResolveInfo[]) {
const fragmentSpreadName = 'Subscriptions'
const allFragments = createFragments(infos, fragmentSpreadName)
const document = `
query RelevantRunSpecsDataSource_Specs(
$ids: [ID!]!
) {
cloudNodesByIds(ids: $ids) {
id
... on CloudRun {
...${fragmentSpreadName}
}
}
pollingIntervals {
runByNumber
}
}
${allFragments.map((fragment) => `${print(fragment) }\n`).join('\n')}
`
return gql(document)
}
}
/**
* Creates an array of GraphQL fragments that represent each of the queries being requested for the set of subscriptions
* that are using the poller created by this class
*
* @example
* The set of fragments will look like the following with `combinedFragmentName` set to "Subscriptions"
* and an array of 2 "infos" and the expected type of CloudRun:
*
* fragment Subscriptions on CloudRun {
* ...Fragment0
* ...Fragment1
* }
*
* fragment Fragment0 on CloudRun {
* { selections from the first GraphQLResolveInfo}
* }
*
* fragment Fragment1 on CloudRun {
* { selections from the second GraphQLResolveInfo}
* }
*
*
* @param infos array of `GraphQLResolveInfo` objects for each subscription using this datasource
* @param combinedFragmentName name for creating the fragment that combines together all the child fragments
*/
const createFragments = (infos: GraphQLResolveInfo[], combinedFragmentName: string) => {
const fragments = infos.map((info, index) => createFragment(info, index))
const fragmentNames = fragments.map((fragment) => fragment.name.value)
const combinedFragment = createCombinedFragment(combinedFragmentName, fragmentNames, infos[0]!.returnType)
return [combinedFragment, ...fragments]
}
/**
* Generate a GraphQL fragment that uses the selections from the info parameter
*
* NOTE: any aliases for field names are removed since these will be
* applied on the front end
*
* @example
* fragment Fragment0 on CloudRun {
* { selections from the GraphQLResolveInfo}
* }
*
* @param info to use for selections for the generated fragment
* @param index value to use as suffix for the fragment name
*/
const createFragment = (info: GraphQLResolveInfo, index: number) => {
const fragmentType = info.returnType.toString()
//remove aliases
const newFieldNode = visit(info.fieldNodes[0]!, {
enter (node) {
const newNode = {
...node,
alias: undefined,
}
return newNode
},
})
const selections = newFieldNode.selectionSet?.selections!
return {
kind: 'FragmentDefinition' as const,
name: { kind: 'Name' as const, value: `Fragment${index}` },
typeCondition: {
kind: 'NamedType' as const,
name: { kind: 'Name' as const, value: fragmentType },
},
selectionSet: {
kind: 'SelectionSet' as const,
selections,
},
}
}
/**
* Generates a fragment that contains other fragment spreads
*
* @example
* fragment CombinedFragment on Type {
* ...Fragment0
* ...Fragment1
* }
*
* @param name name to be used for the fragment
* @param fragmentNames array of names to generate fragment spreads
* @param type of the fragment
*/
const createCombinedFragment = (name: string, fragmentNames: string[], type: GraphQLOutputType) => {
return {
kind: 'FragmentDefinition' as const,
name: { kind: 'Name' as const, value: name },
typeCondition: {
kind: 'NamedType' as const,
name: { kind: 'Name' as const, value: type.toString() },
},
selectionSet: {
kind: 'SelectionSet' as const,
selections: fragmentNames.map((fragmentName) => {
return {
kind: 'FragmentSpread' as const,
name: { kind: 'Name' as const, value: fragmentName },
}
}),
},
}
}