Walkthrough
The nodes and apps built out in this repository are intended to demonstrate the real-time, cross-node data synchronization patterns enabled by the underlying libraries that they are built upon. The sections that follow will walk through the scenarios that demonstrate these patterns and highlight some of the infrastructure that enables the associated functionality.
This guide assumes that you have already followed the steps in getting-started.
This guide will provide a high-level overview of how the underlying functionality is enabled. The Tutorial will provide a thorough step-by-step guide for how to build a node and an app with the underlying libraries.
Also, a lot of the infrastructure demonstrated in this walkthrough is from the underlying libraries. There is a lot of complexity that is exposed here to illustrate how this functionality is enabled. Reference the actual nodes (Proposals and Workflows) and apps (Proposals and Workflows) to see how the libraries simplify the implementation details.
Setup
If you haven't already, make sure that all nodes and apps are running. Be sure the apps are started with the approprate command (npm run start:dev
if running local or npm run start:code
if running in a codespace). The sections that follow will demonstrate how the API facilitates real time data synchronization and cross-node interactivity. With this in mind, you will want to configure two browser windows side by side with each app running in tabs in both windows:
Internal Node Synchronization
Mutations to data within a node are executed through Command
methods. This is typically exposed as a Save
method on a specific Entity
type internally owned by the node. When a Command
is executed, an Event
is broadcast with a message containing the affected data. Any node or application with access to a Listener
associated with the affected data can then react to the change in state.
If additional data mutations need to occur as a result of the command, these interactions can be defined through the use of Hook
methods. These hooks represent points in the Command
method where functionality can be injected. For instance, OnAdd
is executed prior to the mutations implemented in the internal Add
method. On the other hand, AfterUpdate
is executed after the mutations implemented in the internal Update
method. OnSave
is executed prior to the mutations of either Add
or Update
via the public Save
method.
You'll notice that the Save
and Remove
methods are transactional through the use of db.Database.BeginTransactionAsync
. If any aspect of the method fails, to include the result of any hook calls, the entire transaction is rolled back.
To see this in action, execute the following steps with both browser windows showing the Proposals app:
Click the Create Proposal button indicated by the plus icon to the left of the Home heading.
Provide a
Title
andValue
in the Add Proposal dialog, then click Save Proposal:
There are two things to note about this transaction:
The
Proposal
record that was created synchronized across all connected instances of the Proposals app.In addition to creating a
Proposal
, an associatedStatus
was created (illustrated by the Created status to the top right of the Proposal card in the application).
The EntityCommand
class defines a SyncAdd
hook that is called after the internal Add
transaction is successful. Every EntityCommand
must be initialized with an EventHub
, which is a strongly-typed SignalR hub that is defined with an IEventHub
interface. When SyncAdd
is called, it broadcasts the added entity through the IEventHub.OnAdd
method.
A TypeScript EventListener
class is provided that allows you to define interfaces to EventHub
instances. In this case, a ProposalListener
is defined and injected into the HomeRoute
. In the ngOnInit
lifecycle hook, the listener is initialized and events are registered.
The ProposalCommand
provides an AfterAdd
hook that generates a created status and saves it to the proposal.
Cross-Node Synchronization
There are three primary interfaces that facilitate cross-node interactivity:
Contract
- an object that is sent to the remote node to initiate interactivity and track progress across the service lifetime.Package
is a Workflows nodeContract
.
Gateway
- the API interface for sending to and retrieving data from a remote node.GatewayController
defines theGateway
API for the Workflows node.WorkflowsGateway
is theGateway
Service for interfacing with the Workflows node.
Listener
- the event listener provided by the remote node for tracking contract events.PackageEventListener
handlesPackage
events generated by Workflows node.
The following scenarios show patterns for working with these interfaces.
Submitting a Contract
The following interactions occur to facilitate submitting a contract to a remote node:
- The data for the contract is filled out by the initiating node
- The contract is sent through the gateway interface for the remote node
- The remote node receives and processes the contract
- The remote node broadcasts an event when the contract has been successfully processed
To see this in action, set the left browser tab to the Proposals app and the right browser tab to the Workflows app. Execute the following steps:
In the Proposals app on the Documentation Proposal card, click the middle Submit Package icon button represented by a blue right-facing arrow.
A PackageDialog will open. Provide entries for
Title
andValue
, then click Save Package.
The PackageDialog
used to submit the package, along with its associated PackageForm
, are exposed through the @distributed/toolkit
library as part of the common infrastructure for interfacing with the Workflows node. When the Package
contract is saved through the PackageForm, it uses the WorkflowsGateway
service to submit the package. The gateway endpoint is configured within each app whenever the ToolkitModule
is imported (see Proposals - AppModule).
The GatewayController.SubmitPackage
method in the workflows node passes the received package to the PackageCommand.Submit
method. Since this is a new package, the Save
method will be called, which will trigger the PackageCommand.SyncAdd
hook (in the same way as the ProposalCommand
service when a new Proposal is created).
The PackageListener
defined in @distributed/toolkit
is injected in the home route of both the proposals app and the workflows app. Both routes initialize the listener in the respective ngOnInit
lifecycle hooks: see proposals and workflows.
The proposals app uses an Observable
trigger to feed into each ProposalCard
. The card component uses this trigger to handle package events if the received event is associated with its proposal.
If you retrieve the Proposal
both before and after submitting the Package
, you'll notice that a packageId
is assigned when a Package
is successfully submitted:
Before
{
"statusId": 1,
"title": "Documentation Proposal",
"id": 1,
"type": "Proposals.Entities.Proposal",
"value": "A demonstrative Proposoal for documentation purposes.",
"dateCreated": "2023-10-13T12:38:52.3668313",
"dateModified": "2023-10-13T13:11:11.73073"
}
After
{
"statusId": 1,
"packageId": 1,
"title": "Documentation Proposal",
"id": 1,
"type": "Proposals.Entities.Proposal",
"value": "A demonstrative Proposoal for documentation purposes.",
"dateCreated": "2023-10-13T12:38:52.3668313",
"dateModified": "2023-10-13T13:15:11.73073"
}
Leveraging an EventListener
is not exclusive to client apps. In fact, synchronizing node data that's associated with contracts in remote nodes is exclusively handled through EntitySaga
services through events.
In this case, the proposals node defines a PackageListener
that reacts to Package
events. In this case, whenever a Package
is added, the OnAdd
event triggers the PackageSaga.OnAdd
method. Internal changes spawned by a Saga
will still broadcast update events for the affected Entities.
Reacting to Contract Changes
The PackageEventHub
defined by the workflows node provides an additional OnStateChanged
event. This is used to isolate changes to Package.State
from changes to the editable Package
entity fields.
In the PackageCommand
service, there is validation to ensure state is not improperly modified as well as validation to ensure proper state transitions. Also, a Package
can only be modified if it is not in a completed state.
The PackageCommand
service also defines methods for changing the state of a Package
.
In the Workflows app, pending packages provide three actions for handling that package:
From left to right, those actions are:
- Reject - permanently reject the package that was submitted
- Return - return the package that was submitted for corrections
- Approve - approve the package and change the status to the associated
Entity
The following clips demonstrate returning, resubmitting, and approving:
Returning a Package
Resubmitting a Package
Approving a Package
The Proposal
is able to detect when the associated Package
is complete because the PackageListener.OnStateChanged
event triggers the PackageSaga.OnStateChanged
method. The ProposalCardComponent
is able to track whether a Package
can be submitted via the canSubmit
function, which is kept synchronized through the handlePackageEvent
trigger.
Cleaning Up Contracts
It's easy to synchronize internal state with changes to contracts through events because the node is aware of the remote node. The inverse, however, is not true. The remote node is never aware of the nodes that consume it. The only link it ever has to its dependent nodes is through the contract data it receives. If a Package
contract is submitted based on an associated Proposal
and the Proposal
is later removed, how does the corresponding Package
get cleaned up as well?
By leveraging Command
hooks and Gateway
services, associated contract data can be cleaned up. The following clip demonstrates submitting a Package
, then removing the associated Proposal
:
If you look at the ProposalCommand
service, you'll see that the WorkflowsGateway
service is injected in the constructor. The AfterRemove
hook tries to retrieve an active Package
associated with the Proposal
being removed. If it is found, the WorkflowsGateway.WithdrawPackage
method is called. If the package is unable to be withdrawn for any reason, the whole transaction will be aborted and the Proposal
will not be removed.