-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
269 additions
and
79 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,100 +1,39 @@ | ||
# Introduction | ||
|
||
**Aper** is a Rust data structure library. Essentially, it lets you create a `struct` that can be synchronized across multiple instances of your program running across a network. | ||
**Aper** is a Rust data synchronization library. Fundamentally, Aper lets you create a `struct` that can be synchronized across multiple instances of your program, possibly running across a network. | ||
|
||
Use-cases of Aper include: | ||
- managing the state of an application with real-time collaboration features | ||
- creating an timestamped audit trail of an arbitrary data structure | ||
- synchronizing the game state of a multiplayer game. | ||
|
||
The core `aper` library implements the underlying data structures and algorithms, but is agnostic to the | ||
actual mechanism for transfering data on a network. The crates `aper-websocket-client` and `aper-serve` provide a client | ||
and server implementation aimed at synchronizing state across multiple `WebAssembly` clients using `WebSockets`. | ||
The core `Aper` library is not tied to a particular transport, but works nicely with WebSocket. The `aper-websocket-client` and `aper-serve` crates define WebAssembly-based client and server libraries for Aper data structures. | ||
|
||
## `AperSync` | ||
## Design Goals | ||
|
||
Aper defines two core traits: `AperSync`, which we'll talk about here, and `Aper`, which we'll talk about soon. | ||
Aper is designed for **server-authoritative** synchronization, where one instance of an Aper program is considered the “server”, and others are ”clients”. | ||
|
||
`AperSync` means that the struct can be synchronized *unidirectionally*. An `AperSync` struct does not own its own data; instead, its fields are references into a `Store`. `Store` is a hierarchical map data structure provided by Aper that can be synchronized across a network. | ||
This is in contrast to conflict-free replicated data types (CRDTs), which are designed to work in peer-to-peer environments. A design goal of Aper is to allow developers to take full advantage of the server authority, which makes it possible to enforce data invariants. | ||
|
||
Typically, you will not implement `AperSync` directly, but instead derive it. For example, here's a simple `AperSync` struct that could represent an item in a to-do list: | ||
The other guiding goals of Aper are: | ||
|
||
```rust | ||
use aper::{AperSync, data_structures::Atom}; | ||
- Local (optimistic) updates should be fast. | ||
- Data synchronization concerns should not live in application code. | ||
|
||
#[derive(AperSync)] | ||
struct ToDoItem { | ||
done: Atom<bool>, | ||
name: Atom<String>, | ||
} | ||
``` | ||
Aper is designed for structured/nested data. It is not optimized for long, flat sequences like text. | ||
|
||
In order to derive `AperSync`, **every field must implement AperSync**. Typically, this means that fields will either be data structures imported from the `aper::data_structures::*` module, or `structs` that you have derived `AperSync` on. | ||
## Overview | ||
|
||
`Atom` is the most basic `AperSync` type; it represents an atomic value with the provided type. Any serde-serializable type can be used, but keep in mind that these values are opaque to the synchronization system and any modifications mean replacing them entirely. | ||
Aper provides a number of traits and structs that are key to understanding Aper. | ||
|
||
Generally, for compound data structures, you should use more appropriate types. Here's an example of using `AtomMap`: | ||
The **`Store`** struct is the core data store in Aper. Aper knows how to synchronize a `Store` *one-way* across a network, i.e. from the server to clients. | ||
|
||
```rust | ||
use aper::{AperSync, data_structures::AtomMap}; | ||
The **`AperSync`** trait designates a struct that expects to be stored in a `Store`. An `AperSync` struct is really just a reference into some data in the store, along with associated methods for interpreting it as Rust types. | ||
|
||
#[derive(AperSync)] | ||
struct PhoneBook { | ||
name_to_number: AtomMap<String, String>, | ||
} | ||
``` | ||
Since a `Store` can be synchronized by Aper, and `AperSync` is just a reference to data in a `Store`, `AperSync` types can be synchronized. | ||
|
||
The `Atom` in `AtomMap` refers to the fact that the **values** of the map act like `Atom`s: they do not need to implement `AperSync`, but must be (de)serializable. | ||
But that synchronization is only one-way: from server to clients. Generally, clients will also want to modify the data, which is where the `Aper` trait comes in. | ||
|
||
Aper also provides a type of map where values are `AperSync`. This allows more fine-grained updates to the data structure. For example, you might want to create a todo list by mapping a unique ID to a `ToDoItem`: | ||
The **`Aper`** trait designates a struct as being *bidirectionally* synchronizable. It defines a set of actions (called *intents*) that can be performed on the store to update it. | ||
|
||
```rust | ||
use aper::{AperSync, data_structures::{Atom, Map}}; | ||
|
||
#[derive(AperSync)] | ||
struct ToDoItem { | ||
pub done: Atom<bool>, | ||
pub name: Atom<String>, | ||
} | ||
|
||
#[derive(AperSync)] | ||
struct ToDoList { | ||
pub items: Map<String, ToDoItem>, | ||
} | ||
``` | ||
|
||
## Using `AperSync` types | ||
|
||
`AperSync` structs are constructed by “attaching” them to a `Store`. Every `AperSync` type implicitly has a default | ||
value, which is what you get when you attach it to an empty `Store`. | ||
|
||
When modifying collections of `AperSync` like `Map`, you don't insert new values directly. Instead, you call a method like | ||
`get_or_create` that creates the value as its default, and then call mutators on the value that is returned, like so: | ||
|
||
```rust | ||
# use aper::{data_structures::{Atom, Map}}; | ||
use aper::{AperSync, Store}; | ||
|
||
# #[derive(AperSync)] | ||
# struct ToDoItem { | ||
# pub done: Atom<bool>, | ||
# pub name: Atom<String>, | ||
# } | ||
# | ||
# #[derive(AperSync)] | ||
# struct ToDoList { | ||
# pub items: Map<String, ToDoItem>, | ||
# } | ||
|
||
fn main() { | ||
let store = Store::default(); | ||
let mut todos = ToDoList::attach(store.handle()); | ||
|
||
let mut todo1 = todos.items.get_or_create(&"todo1".to_string()); | ||
todo1.name.set("Do laundry".to_string()); | ||
|
||
let mut todo2 = todos.items.get_or_create(&"todo2".to_string()); | ||
todo2.name.set("Wash dishes".to_string()); | ||
todo2.done.set(true); | ||
} | ||
``` | ||
**`AperClient`** and **`AperServer`** provide a “sans-I/O” client/server sync protocol, implemented for the client- and server-side respectively. Typically, you will not use them directly from application code, but instead use crates like `aper-websocket-client` that use them in combination with a particular I/O library. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
## One-Way Synchronization | ||
|
||
`AperSync` means that the struct can be synchronized *unidirectionally*. An `AperSync` struct does not own its own data; instead, its fields are references into a `Store`. `Store` is a hierarchical map data structure provided by Aper that can be synchronized across a network. | ||
|
||
Typically, you will not implement `AperSync` directly, but instead derive it. For example, here's a simple `AperSync` struct that could represent an item in a to-do list: | ||
|
||
```rust | ||
use aper::{AperSync, data_structures::Atom}; | ||
|
||
#[derive(AperSync)] | ||
struct ToDoItem { | ||
done: Atom<bool>, | ||
name: Atom<String>, | ||
} | ||
``` | ||
|
||
In order to derive `AperSync`, **every field must implement AperSync**. Typically, this means that fields will either be data structures imported from the `aper::data_structures::*` module, or `structs` that you have derived `AperSync` on. | ||
|
||
`Atom` is the most basic `AperSync` type; it represents an atomic value with the provided type. Any serde-serializable type can be used, but keep in mind that these values are opaque to the synchronization system and any modifications mean replacing them entirely. | ||
|
||
Generally, for compound data structures, you should use more appropriate types. Here's an example of using `AtomMap`: | ||
|
||
```rust | ||
use aper::{AperSync, data_structures::AtomMap}; | ||
|
||
#[derive(AperSync)] | ||
struct PhoneBook { | ||
name_to_number: AtomMap<String, String>, | ||
} | ||
``` | ||
|
||
The `Atom` in `AtomMap` refers to the fact that the **values** of the map act like `Atom`s: they do not need to implement `AperSync`, but must be (de)serializable. | ||
|
||
Aper also provides a type of map where values are `AperSync`. This allows more fine-grained updates to the data structure. For example, you might want to create a todo list by mapping a unique ID to a `ToDoItem`: | ||
|
||
```rust | ||
use aper::{AperSync, data_structures::{Atom, Map}}; | ||
use uuid::Uuid; | ||
|
||
#[derive(AperSync)] | ||
struct ToDoItem { | ||
pub done: Atom<bool>, | ||
pub name: Atom<String>, | ||
} | ||
|
||
#[derive(AperSync)] | ||
struct ToDoList { | ||
pub items: Map<Uuid, ToDoItem>, | ||
} | ||
``` | ||
|
||
## Using `AperSync` types | ||
|
||
`AperSync` structs are constructed by “attaching” them to a `Store`. Every `AperSync` type implicitly has a default | ||
value, which is what you get when you attach it to an empty `Store`. | ||
|
||
When modifying collections of `AperSync` like `Map`, you don't insert new values directly. Instead, you call a method like | ||
`get_or_create` that creates the value as its default, and then call mutators on the value that is returned, like so: | ||
|
||
```rust | ||
# use aper::{data_structures::{Atom, Map}}; | ||
# use uuid::Uuid; | ||
use aper::{AperSync, Store}; | ||
|
||
# #[derive(AperSync)] | ||
# struct ToDoItem { | ||
# pub done: Atom<bool>, | ||
# pub name: Atom<String>, | ||
# } | ||
# | ||
# #[derive(AperSync)] | ||
# struct ToDoList { | ||
# pub items: Map<Uuid, ToDoItem>, | ||
# } | ||
|
||
fn main() { | ||
let store = Store::default(); | ||
let mut todos = ToDoList::attach(store.handle()); | ||
|
||
let mut todo1 = todos.items.get_or_create(&Uuid::new_v4()); | ||
todo1.name.set("Do laundry".to_string()); | ||
|
||
let mut todo2 = todos.items.get_or_create(&Uuid::new_v4()); | ||
todo2.name.set("Wash dishes".to_string()); | ||
todo2.done.set(true); | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
## Bidirectional Synchronization | ||
|
||
Synchronization in Aper is *asymmetric*, meaning that the method of synchronizing | ||
the data structure from the server to the client is different from the method of | ||
synchronizing from the client to the server. | ||
|
||
The server sends changes to the client by telling it directly how to modify its | ||
`Store`. These messages are called *mutations*. | ||
|
||
For the client to send changes to the server, you need to supply two things: | ||
|
||
- An *intent* type (usually an `enum`), that can represent actions that can be taken | ||
on the data to modify it. | ||
- An `apply` function that takes the data structure and an intent, and updates the | ||
data structure accordingly. | ||
|
||
Both of these, as well as an error type, are provided through the `Aper` trait. | ||
|
||
### Example | ||
|
||
In the last section, we implemented a `ToDoList`: | ||
|
||
```rust | ||
use aper::{AperSync, data_structures::{Atom, Map}}; | ||
|
||
#[derive(AperSync)] | ||
struct ToDoItem { | ||
pub done: Atom<bool>, | ||
pub name: Atom<String>, | ||
} | ||
|
||
#[derive(AperSync)] | ||
struct ToDoList { | ||
pub items: Map<String, ToDoItem>, | ||
} | ||
``` | ||
|
||
To create an **intent** type, we should consider the actions a user might take. A minimal set of intents (inspired by [TodoMVC](https://todomvc.com/)) is: | ||
|
||
- Create a new task | ||
- Change the name of an existing task | ||
- Mark a task as done / not done | ||
- Remove all completed tasks | ||
|
||
In code, that looks like: | ||
|
||
```rust | ||
use uuid::Uuid; | ||
use serde::{Serialize, Deserialize}; | ||
|
||
#[derive(Serialize, Deserialize, Clone, std::cmp::PartialEq)] | ||
enum ToDoIntent { | ||
CreateTask { | ||
id: Uuid, | ||
name: String, | ||
}, | ||
RenameTask { | ||
id: Uuid, | ||
name: String, | ||
}, | ||
MarkDone { | ||
id: Uuid, | ||
done: bool, | ||
}, | ||
RemoveCompleted, | ||
} | ||
``` | ||
|
||
Note that when we take action on an existing task, we need a way to identify it, | ||
so we give each task a universally unique identifier (UUID). This UUID is generated | ||
on the client and sent as part of the `CreateTask` message. | ||
|
||
This is a bit different from what you might expect if you're used to remote procedure | ||
call (RPC) APIs, where the server is responsible for generating IDs. It's important | ||
here because the client may need to create an intent that refers to a task before it | ||
hears back from the server with an ID (for example, if the network is interrupted | ||
or the user has gone offline.) | ||
|
||
Now, we implement `Aper` for `ToDoList`: | ||
|
||
```rust | ||
# use aper::{AperSync, data_structures::{Atom, Map}}; | ||
# use serde::{Serialize, Deserialize}; | ||
# use uuid::Uuid; | ||
# | ||
# #[derive(AperSync)] | ||
# struct ToDoItem { | ||
# pub done: Atom<bool>, | ||
# pub name: Atom<String>, | ||
# } | ||
# | ||
# #[derive(AperSync)] | ||
# struct ToDoList { | ||
# pub items: Map<Uuid, ToDoItem>, | ||
# } | ||
# | ||
# #[derive(Serialize, Deserialize, Clone, std::cmp::PartialEq)] | ||
# enum ToDoIntent { | ||
# CreateTask { | ||
# id: Uuid, | ||
# name: String, | ||
# }, | ||
# RenameTask { | ||
# id: Uuid, | ||
# name: String, | ||
# }, | ||
# MarkDone { | ||
# id: Uuid, | ||
# done: bool, | ||
# }, | ||
# RemoveCompleted, | ||
# } | ||
|
||
use aper::Aper; | ||
|
||
impl Aper for ToDoList { | ||
type Intent = ToDoIntent; | ||
type Error = (); | ||
|
||
fn apply(&mut self, intent: &ToDoIntent) -> Result<(), ()> { | ||
match intent { | ||
ToDoIntent::CreateTask { id, name } => { | ||
let mut item = self.items.get_or_create(id); | ||
item.name.set(name.to_string()); | ||
item.done.set(false); | ||
}, | ||
ToDoIntent::RenameTask { id, name } => { | ||
// Unlike CreateTask, we bail early with an `Err` if | ||
// the item doesn't exist. Most likely, the server has | ||
// seen a `RemoveCompleted` that removed the item, but | ||
// a client attempted to rename it before the removal | ||
// was synced to it. | ||
let mut item = self.items.get(id).ok_or(())?; | ||
item.name.set(name.to_string()); | ||
} | ||
ToDoIntent::MarkDone { id, done } => { | ||
let mut item = self.items.get(id).ok_or(())?; | ||
item.done.set(*done); | ||
} | ||
ToDoIntent::RemoveCompleted => { | ||
// TODO: need some way to iterate from Map first! | ||
} | ||
} | ||
|
||
Ok(()) | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
# Summary | ||
|
||
- [Introduction](./01-introduction.md) | ||
- [One-Way Synchronization](./02-one-way-sync.md) | ||
- [Bidirectional Synchronization](./03-bidirectional-sync.md) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.