Skip to content

Commit

Permalink
book work
Browse files Browse the repository at this point in the history
  • Loading branch information
paulgb committed Sep 2, 2024
1 parent df48143 commit 2ecd661
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 79 deletions.
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion aper/src/data_structures/map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ impl<K: Serialize + DeserializeOwned, V: AperSync> Map<K, V> {
pub fn delete(&mut self, key: &K) {
let key = bincode::serialize(key).unwrap();
self.map.delete_child(&key);
}
}
}
95 changes: 17 additions & 78 deletions site/book/src/01-introduction.md
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.
87 changes: 87 additions & 0 deletions site/book/src/02-one-way-sync.md
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);
}
```
148 changes: 148 additions & 0 deletions site/book/src/03-bidirectional-sync.md
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(())
}
}
```
2 changes: 2 additions & 0 deletions site/book/src/SUMMARY.md
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)
1 change: 1 addition & 0 deletions site/doctests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ publish = false
aper = { path = "../../aper" }
doc-comment = "0.3.3"
serde = { version = "1.0.123", features = ["derive"] }
uuid = { version = "1.10.0", features = ["v4", "serde"] }

Loading

0 comments on commit 2ecd661

Please sign in to comment.