Introduction
My teams main responsibility is to aggregate data from various microservices, and then transform it into larger domain
models that other teams are able to consume. As an example, a movie might require metadata, recommendations, the users
potential progress, overrides from an editor, and so on. However, for performance reasons, we also need the capability
to provide APIs which serves thinner slices of every model.
In this post, I'll discuss a recent POC I did to try and make this work easier. We'll look at some typescript code that
infers types from trees, some trade-offs with directed acyclical graphs, as well as writing tests for an
asynchronous traversal. All of the code is available
API design
When working on packages and different types of abstractions, I like to start with code that consumes the API as if it
were already written. This helps me avoid sunk cost fallacy – that is, having invested so much time in the internals
that I feel forced to find a use-case for it. If the abstraction feels weird or the API is unclear, I'll scrap the idea
early.
Ok, so let's proceed by trying to stub out the API. My purpose with this POC was to develop a method for defining our
domain models in such a way that each property could be lazy-loaded as needed, hence enabling us to generate subsets of
these models on the fly with just the data you need.
The initial code I wrote to visualize what the API could look like used a sort of builder pattern like this:
// getMoviesForUserSubscription combines two models to get the movies that the user is able to watch.
async function getMoviesForUserSubscription() {
const subscription = await models.subscription.withUserPackage().compile()
const movies = await models.movie
.withSubscriptionInfo()
.withProgress()
.withRecommendations()
.withPosterImages()
.compile()
return movies.filter((m) => m.subscriptionInfo.subscription.id == subscription.id)
}
Simply pressing .
(dot) in the editor, and have the autocomplete outline each model within our domain seemed like an
intuitive way to get the data that you needed.
Next, I had to find a way to construct these models and declare their dependencies. At first, I considered using a
directed graph. It was evident that situations could arise where it would be useful to have multiple edges to the same
node:
┌────────┐
│ Series │
└────────┘
│
┌───────────────┘ ┌────────┐
│ │ Movie │─────┐
▼ └────────┘ │
┌────────┐ │ │
│Seasons │──────┐ │ │
└────────┘ │ │ │
│ │ │ ▼
│ │ ┌─────┘ ┌────────┐
│ │ │ │Trailer │
▼ │ │ └────────┘
┌────────┐ │ │ │
│Episodes│ │ │ │
└────────┘ │ ▼ │
│ │ ┌────────┐ │
└──────────┴─────▶│Progress│◀──────────┘
└────────┘
In the example above, a user could have progress on a series, season, episode, movie and trailer. If we wanted to derive
a submodel from that graph it would have to look something like this:
const episodeWithProgress = await seriesModel.withSeasons().withEpisodes().withProgress().compile()
const movieWithProgress = await movieModel.withProgress().compile()
However, permitting multiple edges to connect to a single node introduces ambiguity in the path to reach it, which would
necessitate explicit instructions for the traversal.
I think this would negatively impact the API because you would either need prior knowledge of the traversal sequence, or
would have to rely heavily on your editors autocomplete until you were able to pinpoint the path to the property you
wanted.
We were also not overly concerned with the potential network overhead from retrieving more data than necessary. Using
something like GraphQL makes it easy to pick the fields you want with precision, but it can also result in very verbose
queries when working with large models. On top of this, it makes the traversal much slower.
If we instead were to consider the series and movie models as two distinct trees:
┌────────┐ ┌────────┐
┌─────▶│ Series │◀─────┐ │ Movie │
│ └────────┘ │ └────────┘
│ │ ▲
│ │ │
┌────────┐ ┌────────┐ ┌────────┐
┌──▶│Seasons │◀──┐ │Progress│ │Progress│
│ └────────┘ │ └────────┘ └────────┘
│ │
│ │
┌────────┐ ┌────────┐
│Progress│ │Episodes│
└────────┘ └────────┘
▲
│
┌────────┐
│Progress│
└────────┘
We've essentially reversed the direction of the edges so that each node now points to its parent instead. Given there's
just one path from any node to the root, we can make the traversal implicit. Users woukd be able to access leaf nodes
directly on the model without needing knowledge of the path to resolve it:
// No need to keep hitting . to try and figure out how a node is
// reached. The traversal happens automatically within the library.
const episodesWithprogress = await seriesModel.episodesWithprogress().compile()
This would make the API for consuming the data much nicer, imo. However, treating each model as a distinct tree could
result in considerable code duplication.
At this point, I felt the need to draw out a few more domain models to evaluate the tree structures. After doing so, I
concluded that most trees were broad but not very deep.
Primarily, I wanted to have the capability to segment out costly nodes when the data wasn't necessary:
┌──────────┐
┌──────────────▶│ Movie │◀───────────────┐
│ └──────────┘ │
│ ▲ │
│ ┌──────┴──────┐ │
│ │ │ │
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Progress │ │IsFavorite│ │ Rating │ │ Images │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
With a data structure in mind, I also wanted to test the process of breaking out a new node when I had determined that a
certain call was costly, and not always essential. Let's say that the progress request below is expensive and difficult
to cache:
import { declareModel } from './model'
async function fetchFullMovie(id: number) {
const metadataRequest = Promise.resolve({ id, title: 'Movie title', rating: 4.5 })
const imagesRequest = Promise.resolve([{ src: 'https://example.com/image.jpg' }])
const progressRequest = Promise.resolve({ watched: 0.5 })
const [metadata, images, progress] = await Promise.all([metadataRequest, imagesRequest, progressRequest])
return { id, metadata, images, progress }
}
const movieRootNode = { name: 'movie' as const, run: fetchFullMovie }
const movieModel = declareModel(movieRootNode)
const movie = await movieModel.compile(1)
To improve the performance of our system, we'd like the possibility to extract that call to its own node so that it is
only fetched when we need it.
_NOTE:_ For the sake of simplicity, I've created our current tree with a single node. Nonetheless, the procedure for
dividing a node and introducing new branches should remain consistent, irrespective of the node's position in within the
tree.
The first thing we would have to do with our current API would be to separate the fetch functions:
async function fetchMovie(id: number) {
const metadataRequest = Promise.resolve({ title: 'Movie title', rating: 4.5 })
const imagesRequest = Promise.resolve([{ src: 'https://example.com/image.jpg' }])
const [metadata, images] = await Promise.all([metadataRequest, imagesRequest])
return { id, ...metadata, images }
}
async function fetchProgress<T extends { id: number }>(obj: T) {
return Promise.resolve({ watched: `${obj.id * 2}%` })
}
We're then able to declare a new child node that we can use to grab the progress:
const rootNode = { name: 'movie' as const, run: fetchMovie }
const progressNode = { name: 'progress' as const, parent: rootNode, run: fetchProgress }
const model = declareModel(rootNode, progressNode)
const movieWithoutProgress = await model.compile(5)
console.log(movieWithoutProgress.id) // 10
const movieWithProgress = await model.withProgress().compile(10)
console.log(movieWithProgress.id) // 10
console.log(movieWithProgress.progress.watched) // 20%
Using the above code, we've constructed the following linear tree:
┌────────┐
│ Movie │
└────────┘
▲
│
┌────────┐
│Progress│
└────────┘
This approach might be acceptable if the movie data is heavily cached. Yet, fetching progress does not depend on the
movie request. As demonstrated in the fetchFullMovie function, we can retrieve data from the upstream APIs in parallel,
as long as we possess the ID.
This posed a slight challenge for the API. One way to eliminate the dependency between our Movie and Progress nodes
could be to introduce a new root node that instantly resolves a promise with the ID. This would make the nodes siblings
on the same layer of our tree, thereby enabling both of them to be executed in parallel:
┌────────┐
┌──────│ Id │──────┐
│ └────────┘ │
│ │
│ │
▼ ▼
┌────────┐ ┌────────┐
│Progress│ │ Movie │
└────────┘ └────────┘
const newRootNode = { name: 'id' as const, run: (id: number) => Promise.resolve(id) }
const newMetadataNode = { name: 'metadata' as const, parent: newRootNode, run: fetchMovie }
const newProgressNode = { name: 'progress' as const, parent: newRootNode, run: fetchProgress }
const model = declareModel(newRootNode, newMetadataNode, newProgressNode)
// Create a model with just the progress
const onlyProgress = await model.withProgress().compile(5)
console.log(onlyProgress.progress.watched)
// Create a model with just the metadata
const onlyMetadata = await model.withMetadata().compile(10)
console.log(onlyMetadata.metadata.rating)
// Create a full model with all data
const fullMovie = await model.withMetadata().withProgress().compile(15)
console.log(fullMovie.progress.watched)
console.log(fullMovie.metadata.rating)
console.log(onlyMetadata.metadata.rating)
Looking at the code above, we can see that the process of attaching a node is simple. All we have to do is specify the
parent node we depend on and implement a 'run' function. This function receives the value that the parent resolves to.
The new node can then leverage this data for its own fetching.
The final aspect I wanted to prototype before diving into the implementation was to see explore how it would look like
if we wanted to "share" nodes between different trees. Consider this diagram where every media type could have progress:
┌────────┐ ┌────────┐
┌─────▶│ Series │◀─────┐ │ Movie │
│ └────────┘ │ └────────┘
│ │ ▲
│ │ │
┌────────┐ ┌────────┐ ┌────────┐
┌──▶│Seasons │◀──┐ │Progress│ │Progress│
│ └────────┘ │ └────────┘ └────────┘
│ │
│ │
┌────────┐ ┌────────┐
│Progress│ │Episodes│
└────────┘ └────────┘
▲
│
┌────────┐
│Progress│
└────────┘
Having to declare multiple nodes like this could lead to substantial code duplication, thereby making the models harder
to maintain.
Therefore, I chose to prototype a simplified version of the two trees above, where both series and movies could have
progress appended to them. The progress node, however, is solely concerned with IDs, and its endpoint can fetch progress
for any media type. Let's see what that might look like:
function fetchSeries(id: number) {
return Promise.resolve({
id,
name: 'name of the series',
seasonIds: ['1', '2', '3'],
})
}
function fetchMovie(id: number) {
return Promise.resolve({
id,
name: 'name of the movie',
})
}
function fetchProgress<T extends { id: number }>(obj: T) {
return Promise.resolve({ percentageWatched: `${obj.id * 2}%` })
}
const seriesRoot = { name: 'series' as const, run: fetchSeries }
const seriesProgress = { name: 'progress' as const, parent: seriesRoot, run: fetchProgress }
const movieRoot = { name: 'movie' as const, run: fetchMovie }
const movieProgress = { name: 'progress' as const, parent: movieRoot, run: fetchProgress }
The code above creates two distinct linear trees. Yet, the only part that gets duplicated between the two trees, are
these two lines:
const seriesProgress = { name: 'progress' as const, parent: seriesRoot, run: fetchProgress }
const movieProgress = { name: 'progress' as const, parent: movieRoot, run: fetchProgress }
By using generic fetch functions, we can conceptually think of the two trees above as a directed acyclic graph as they
are able to share code for fetching and transforming the data:
┌────────┐ ┌────────┐
│ Movie │ │ Series │
└────────┘ └────────┘
│ │
│ │
│ │
│ │
│ ┌────────┐ │
└──▶│Progress│◀──┘
└────────┘
const seriesModel = createModel(seriesRoot, seriesProgress)
const movieModel = createModel(movieRoot, movieProgress)
const series = await seriesModel.withProgress().compile(14)
console.log(series.progress.percentageWatched) // 28%
const movie = await movieModel.withProgress().compile(8)
console.log(movie.progress.percentageWatched) // 16%
Adding types
For the models to be useful, they must be able to accurately infer the types. Let's reuse some of the code from earlier:
const rootNode = { name: 'id' as const, run: (id: number) => Promise.resolve(id) }
const metadataNode = { name: 'metadata' as const, parent: rootNode, run: fetchMovie }
const progressNode = { name: 'progress' as const, parent: rootNode, run: fetchProgress }
const model = createModel(rootNode, metadataNode, progressNode)
In the example above, the model should only contain functions for selecting metadata and progress. Since tree traversal
always starts from the top, the rootNode
is considered the base of our model. Therefore, we'll add the values it
returns to the root of the submodel we attain by calling compile
later.
Consequently, I anticipate the model in the above example to include the following three functions:
model.withMetadata()
model.withProgress()
model.compile()
The functions responsible for lazy-loading the metadata and progress should take the name of the node and append the
word with
. Additionally, I want to ensure the name of the root node is excluded. If included, it should trigger a type
error:
model.withId() // Error: withId() does not exist on type { ... }
For the compile call's return value, I expect the return type to be the intersection of the root node and the return
values from all withX()
invocations:
// { id }
const movieOne = await model.compile()
// { id, progress: { ... } }
const movieTwo = await model.withProgress().compile()
// { id, metadata: { ... } }
const movieThree = await model.withMetadata().compile()
// { id, metadata: { ... }, progress: { ... } }
const movieFour = await model.withMetadata().withProgress().compile()
To add achieve these types we need to traverse the tree, and add functions for accessing each node. The compile function
must keep track of the functions we've invoked to ensure it returns the appropriate type:
type Func<TIn = any, TOut = any> = (arg: TIn) => Promise<TOut>
interface RootNode<N extends string = string, TIn = any, TOut = any> {
name: N
run: Func<TIn, TOut>
}
interface ChildNode<N extends string = string, TIn = any, TOut = any> extends RootNode<N, TIn, TOut> {
parent: TreeNode
}
type TreeNode<N extends string = string, TIn = any, TOut = any> = RootNode<N, TIn, TOut> | ChildNode<N, TIn, TOut>
type IsChildNode<T> = 'parent' extends keyof T ? true : false
type EnsureNodeArray<T> = T extends TreeNode[] ? T : never
type FindRootNode<T extends TreeNode[]> = T extends [infer First, ...infer Rest]
? IsChildNode<First> extends true
? FindRootNode<EnsureNodeArray<Rest>>
: First
: never
type ExtractNode<M extends TreeNode[], N extends string> = Extract<M[number], { name: N }>
type NodeName<M> = M extends TreeNode<infer N>[] ? N : never
type RemoveFuncPrefix<T extends string> = T extends `with${infer U}` ? Uncapitalize<U> : never
type AddFuncPrefix<T extends string> = `with${Capitalize<T>}`
type Model<T, M extends TreeNode[]> = Omit<
{
[K in AddFuncPrefix<NodeName<M>>]: K extends string
? () => Model<T & Record<RemoveFuncPrefix<K>, Awaited<ReturnType<ExtractNode<M, RemoveFuncPrefix<K>>['run']>>>, M>
: never
},
FindRootNode<M> extends { name: string } ? AddFuncPrefix<FindRootNode<M>['name']> : never
> & {
compile: (
...args: FindRootNode<M> extends { run: (...args: any) => any } ? Parameters<FindRootNode<M>['run']> : never
) => Promise<T> &
(FindRootNode<M> extends { run: (...args: any) => any } ? ReturnType<FindRootNode<M>['run']> : never)
}
export function declareModel<T, M extends TreeNode<any, any, any>[]>(...nodes: M): Model<T, M> {
// ...
}
With the types above I could inspecting the code I used earlier to design the API. The types were being inferred
correctly, which meant that it was time to move on to the actual implementation.
Implementation
When working on POCs, where the code may be discarded, I still think it's important to ensure that the external API is
accurately typed. However, when the types are this complex, I sometimes use any
on the internal functions to be able
to quickly iterate on the implementation using "vanilla" javascript.
My vision for the internal workings of the builder was to resolve the tree layer-by-layer. Nodes within the same layer
of the tree could be executed in parallel, and their children would follow in the next cycle, and so on. This approach
felt well-suited for the wide, shallow trees I've described before.
Therefore, the traversal is always going to start at the root - making a function to identify the root node a logical
first step. Since our current types neither mandate a single root node, nor limit its number, I included two runtime
checks:
function extractRootNode<M extends TreeNode[]>(nodes: M) {
const rootNodes = nodes.filter((x) => !('parent' in x))
if (rootNodes.length === 0) {
throw new Error('No root node found')
}
if (rootNodes.length > 1) {
throw new Error('You can only have one root node')
}
return rootNodes[0] as RootNode
}
This is something I could revisit and enforce with types if the POC was a success. Now, before I could think of the
traversal, I had to use the function above to identify the root node, and maintain a record of all nodes that I wanted
to visit:
export function declareModel<T, M extends TreeNode<any, any, any>[]>(...nodes: M): Model<T, M> {
const rootNode = extractRootNode(nodes)
const parentChildrenToVisit = new Map<TreeNode, Set<TreeNode>>()
// ...
}
Next, I created a builder object and attached functions dedicated to visiting each of the nodes. Keep in mind that
visiting the root node is implicit, so it won't be part the API. Therefore, we'll only add nodes with a parent:
export function declareModel<T, M extends TreeNode<any, any, any>[]>(...nodes: M): Model<T, M> {
// ...
const builder: any = {}
// Add functions that, when invoked, marks a node for visitation
nodes.forEach((node) => {
if ('parent' in node) {
builder[addFunctionPrefix(node.name)] = function () {
addNodeToVisit(node)
return builder
}
}
})
}
By looking at the code above, we can see that the function names for the builder are determined by another function
called addFunctionPrefix
. Its role is to add with
as a prefix to the node name, and ensure that the first letter of
the name is capitalized:
function addFunctionPrefix(name: string) {
return `with${name.charAt(0).toUpperCase() + name.slice(1)}`
}
Recall that I aimed for the API to allow users to access any node, regardless of its position in the tree. Now suppose
we add this node, which is located five layers below the root, to our map.
const x = await model.withNodeFiveLayersDown().compile()
There would be no way for us to reach that node directly from the root. Hence, we need to ensure that with each node we
want to visit we also request a visit to its parent, all the way to the top:
export function declareModel<T, M extends TreeNode<any, any, any>[]>(...nodes: M): Model<T, M> {
// ...
const addNodeToVisit = (node: M[number]) => {
// Break the recursion if we've reached the root.
if (!("parent" in node)) {
return
}
// Register that we want to visit this node after our parent node has been processed.
// Remember, a sibling node might already have added itself to the set.
const siblings = parentChildrenToVisit.get(node.parent)
if (siblings) {
siblings.add(node)
} else {
parentChildrenToVisit.set(node.parent, new Set([node]))
}
// This could be a leaf node with no direct connection to the root.
// Therefore, we need to walk up its ancestry path until we reach the
// top, which is going to serve as the starting point of the traversal.
addNodeToVisit(node.parent)
}
// ...
With the capability to attach functions to the builder, and register nodes for visitation, we can move on to the compile
function, which will handle the actual traversal:
export function declareModel<T, M extends TreeNode<any, any, any>[]>(...nodes: M): Model<T, M> {
// ...
builder.compile = async (...args: any[]) => {
// Base the model on the return type of the root node.
let model = await (rootNode as any).run(...args)
// Exit early if we don't have any child nodes to visit.
if (parentChildrenToVisit.size === 0) {
return model
}
// We'll slice the tree layer by layer. Here, we'll start with
// nodes that are direct descendants of the root node.
const children = parentChildrenToVisit.get(rootNode)
// The queue will contain tuples: the first value represents
// the node to execute, and the second is the resolved value
// from its parent node, used as input for the "run" function.
const queue = [...(children ?? [])].map((c) => [c, model])
// Continuiously add properties to the model with each layer of nodes.
while (queue.length > 0) {
const nodesWithArgs = queue.splice(0, queue.length)
// These nodes carry their required input in the tuple and can run concurrently.
const promises = nodesWithArgs.map(([node, arg]) => node.run(arg))
const responses = await Promise.all(promises)
// Decorate the model with fields from this layer
model = responses.reduce((acc, cur, i) => ({ ...acc, [nodesWithArgs[i][0].name]: cur }), model)
// Loop through the nodes to see if we have any child nodes marked
// for visitation. If found, we add them to the queue along with
// the node's resolved value, which serves as their input.
for (let i = 0; i < nodesWithArgs.length; i++) {
const childrenToVisit = parentChildrenToVisit.get(nodesWithArgs[i][0])
if (!childrenToVisit) {
continue
}
queue.push(...[...childrenToVisit].map((x) => [x, responses[i]]))
}
}
return model
}
return builder
The traversal logic is fairly simple. We maintain a queue holding tuples of nodes and the resolved values from the
preceding layer. The loop continues until the queue is empty. During each cycle, all nodes in the queue are executed
concurrently. If any of their children are registered for a visit, they are added to the queue.
At this point I was able to execute the code from the previous examples, and could verify that they yielded the results
I expected. The next step would be to write tests to ensure that the traversal was efficient, and that no node was being
called more than once.
Testing
I began by creating a function that would return eight nodes that formed the following tree:
┌─────┐
┌───────▶│ 0 │◀───────┐
│ └─────┘ │
│ │
│ │
│ │
│ │
┌─────┐ ┌─────┐
┌───▶│ 1 │◀──┐ │ 2 │
│ └─────┘ │ └─────┘
│ │ ▲
│ │ │
┌─────┐ ┌─────┐ ┌─────┐
│ 3 │ │ 4 │ ┌───▶│ 5 │◀───┐
└─────┘ └─────┘ │ └─────┘ │
│ │
│ │
┌─────┐ ┌─────┐
│ 6 │ │ 7 │
└─────┘ └─────┘
function createNodes() {
const nodeZero = { name: 'zero' as const, run: (x: number) => resolveValue(x) }
const nodeOne = { name: 'one' as const, parent: nodeZero, run: (_: number) => resolveValue(1) }
const nodeTwo = { name: 'two' as const, parent: nodeZero, run: (_: number) => resolveValue(2) }
const nodeThree = { name: 'three' as const, parent: nodeOne, run: (_: number) => resolveValue(3) }
const nodeFour = { name: 'four' as const, parent: nodeOne, run: (_: number) => resolveValue(4) }
const nodeFive = { name: 'five' as const, parent: nodeTwo, run: (_: number) => resolveValue(5) }
const nodeSix = { name: 'six' as const, parent: nodeFive, run: (_: number) => resolveValue(6) }
const nodeSeven = { name: 'seven' as const, parent: nodeFive, run: (_: number) => resolveValue(7) }
return [nodeZero, nodeOne, nodeTwo, nodeThree, nodeFour, nodeFive, nodeSix, nodeSeven] as const
}
I think the size of this tree is sufficient for testing one of the trickier use cases, where we access just two leaf
nodes, say node 3 and 7.
Subsequently, I added the resolveValue
function that you can see above. The implementation is very straightforward; it
wraps any given value in a promise that resolves immediately using setTimeout:
function resolveValue(value: number): Promise<{ value: number }> {
return new Promise((resolve) => {
setTimeout(() => resolve({ value }), 0)
})
}
This allows me to use fake timers, and advance time to have the callbacks executed. I also added a function for flushing
the promises for the preceding layer, and another to ensure that each node in a given layer is called only once, and
with the correct arguments:
// Used to flush out the promises from the layer above
async function flushNumOfParentNodes(numberOfNodes: number) {
for (let i = 0; i < numberOfNodes; i++) {
await Promise.resolve()
}
}
function assertNodeInvocations(nodes: ReturnType<typeof createNodes>, indexArgs: Array<[number, number]>) {
nodes.forEach((node, i) => {
const idxArg = indexArgs.find(([index]) => index === i)
if (idxArg) {
expect(node.run).toBeCalledTimes(1)
expect(node.run).toBeCalledWith(idxArg[0] == 0 ? idxArg[1] : { value: idxArg[1] })
} else {
expect(node.run).toBeCalledTimes(0)
}
})
}
With that, I could write an actual test:
describe('declareModel', () => {
it('processes the tree layer by layer into a model', async () => {
const nodes = createNodes()
const modelBuilder = declareModel(...nodes)
// Enable fake timers. We'll want to assert that the tree is processed layer
// by layer. We'll also attach spies to the run function of every node.
jest.useFakeTimers()
nodes.forEach((node) => jest.spyOn(node, 'run'))
// We are going to select a subset from the entire domain model by
// picking node 3 and 7. Initially, we won't await this call. This
// will enable us to to make assertions on the traversal.
const modelPromise = modelBuilder.withThree().withSeven().compile(20)
// Layer 1 should only call the run function of the root
// node with the value passed to the compile function.
assertNodeInvocations(nodes, [[0, 20]])
// Proceed to layer 2 by advancing time and flushing the root node promise.
jest.advanceTimersByTime(0)
await flushNumOfParentNodes(1)
// Layer 2 is expected to invoke the 'run' function for nodes 1 and 2, passing
// in the values that have been resolved from each node's direct parent.
assertNodeInvocations(nodes, [
[0, 20],
[1, 20],
[2, 20],
])
// Proceed to layer 3 by advancing time and flushing the promises from layer 2.
jest.advanceTimersByTime(0)
await flushNumOfParentNodes(2)
// At this point, we're experiencing diminishing returns from continuously
// asserting that each subsequent layer wasn't called. However, this is a
// fairly small tree, so I think the extra assertions are justified.
assertNodeInvocations(nodes, [
[0, 20],
[1, 20],
[2, 20],
[3, 1],
[5, 2],
])
// Proceed to layer 4 by advancing time and flushing promises.
jest.advanceTimersByTime(0)
await flushNumOfParentNodes(3)
// Now we should have invoked the run method of node 7 which is our last node.
assertNodeInvocations(nodes, [
[0, 20],
[1, 20],
[2, 20],
[3, 1],
[5, 2],
[7, 5],
])
// Advancing one more time here will allow the modelPromise to resolve.
jest.advanceTimersByTime(0)
// We can now await the modelPromise and assert the structure.
const model = await modelPromise
// The root value is going to be whatever we used to start the tree traversal.
expect(model.value).toBe(20)
expect(model.three.value).toBe(3)
expect(model.seven.value).toBe(7)
})
})
With the test passing, we've confirmed that the nodes are invoked in the correct order, the appropriate number of times,
and with the correct arguments. Additionally, we've asserted that nodes on the same layer are executed in parallel
during the same tick.
The end
I usually tweet something when I've finished writing a new post. You can find me on Twitter
by clicking