Repository: p2panda/aquadoggo (language: rust)

Issue:
GraphQL schema definition for replication protocol



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f21624fef..d3372d491 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - GraphQL endpoint for publishing entries [#123](https://github.com/p2panda/aquadoggo/pull/132)
 - Implement SQL `DocumentStore` [#118](https://github.com/p2panda/aquadoggo/pull/118)
 - Implement SQL `SchemaStore` [#130](https://github.com/p2panda/aquadoggo/pull/130)
+- GraphQL endpoints for replication [#100](https://github.com/p2panda/aquadoggo/pull/100)
 
 ### Changed
 
diff --git a/Cargo.lock b/Cargo.lock
index b69df36d6..eff98f2fa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12,6 +12,21 @@ dependencies = [
  "regex",
 ]
 
+[[package]]
+name = "addr2line"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
 [[package]]
 name = "aead"
 version = "0.4.3"
@@ -87,6 +102,7 @@ name = "aquadoggo"
 version = "0.2.0"
 dependencies = [
  "anyhow",
+ "arrayvec 0.5.2",
  "async-graphql",
  "async-graphql-axum",
  "async-trait",
@@ -96,11 +112,15 @@ dependencies = [
  "directories",
  "envy",
  "futures",
+ "graphql_client",
  "hex",
  "http",
  "hyper",
  "lipmaa-link 0.2.2",
  "log",
+ "lru",
+ "mockall",
+ "mockall_double",
  "openssl-probe",
  "p2panda-rs",
  "rand 0.8.5",
@@ -146,6 +166,12 @@ version = "0.7.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6"
 
+[[package]]
+name = "ascii"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e"
+
 [[package]]
 name = "ascii_utils"
 version = "0.9.3"
@@ -503,6 +529,21 @@ dependencies = [
  "mime",
 ]
 
+[[package]]
+name = "backtrace"
+version = "0.3.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11a17d453482a265fd5f8479f2a3f405566e6ca627837aaddb85af8b1ab8ef61"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
 [[package]]
 name = "bamboo-rs-core-ed25519-yasmf"
 version = "0.1.1"
@@ -764,6 +805,19 @@ dependencies = [
  "vec_map",
 ]
 
+[[package]]
+name = "combine"
+version = "3.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680"
+dependencies = [
+ "ascii",
+ "byteorder",
+ "either",
+ "memchr",
+ "unreachable",
+]
+
 [[package]]
 name = "concurrent-queue"
 version = "1.2.2"
@@ -1260,6 +1314,28 @@ version = "2.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71"
 
+[[package]]
+name = "failure"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86"
+dependencies = [
+ "backtrace",
+ "failure_derive",
+]
+
+[[package]]
+name = "failure_derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
 [[package]]
 name = "fake-simd"
 version = "0.1.2"
@@ -1509,7 +1585,7 @@ dependencies = [
  "cfg-if",
  "js-sys",
  "libc",
- "wasi 0.10.2+wasi-snapshot-preview1",
+ "wasi 0.10.0+wasi-snapshot-preview1",
  "wasm-bindgen",
 ]
 
@@ -1534,6 +1610,12 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "gimli"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4"
+
 [[package]]
 name = "gloo-timers"
 version = "0.2.4"
@@ -1546,6 +1628,64 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "graphql-introspection-query"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "graphql-parser"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5613c31f18676f164112732202124f373bb2103ff017b3b85ca954ea6a66ada"
+dependencies = [
+ "combine",
+ "failure",
+]
+
+[[package]]
+name = "graphql_client"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9b58571cfc3cc42c3e8ff44fc6cfbb6c0dea17ed22d20f9d8f1efc4e8209a3f"
+dependencies = [
+ "graphql_query_derive",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "graphql_client_codegen"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4bf9cd823359d74ad3d3ecf1afd4a975f4ff2f891cdf9a66744606daf52de8c"
+dependencies = [
+ "graphql-introspection-query",
+ "graphql-parser",
+ "heck",
+ "lazy_static",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "syn",
+]
+
+[[package]]
+name = "graphql_query_derive"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e56b093bfda71de1da99758b036f4cc811fd2511c8a76f75680e9ffbd2bb4251"
+dependencies = [
+ "graphql_client_codegen",
+ "proc-macro2",
+ "syn",
+]
+
 [[package]]
 name = "group"
 version = "0.10.0"
@@ -1970,6 +2110,15 @@ dependencies = [
  "value-bag",
 ]
 
+[[package]]
+name = "lru"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32613e41de4c47ab04970c348ca7ae7382cf116625755af070b008a15516a889"
+dependencies = [
+ "hashbrown",
+]
+
 [[package]]
 name = "maplit"
 version = "1.0.2"
@@ -2046,6 +2195,15 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
 
+[[package]]
+name = "miniz_oxide"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082"
+dependencies = [
+ "adler",
+]
+
 [[package]]
 name = "mio"
 version = "0.8.3"
@@ -2085,6 +2243,18 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "mockall_double"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae71c7bb287375187c775cf82e2dcf1bef3388aaf58f0789a77f9c7ab28466f6"
+dependencies = [
+ "cfg-if",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "multer"
 version = "2.0.2"
@@ -2189,6 +2359,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "object"
+version = "0.28.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40bec70ba014595f99f7aa110b84331ffe1ee9aece7fe6f387cc7e3ecda4d456"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "once_cell"
 version = "1.10.0"
@@ -2875,6 +3054,12 @@ dependencies = [
  "sha2 0.9.9",
 ]
 
+[[package]]
+name = "rustc-demangle"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342"
+
 [[package]]
 name = "rustc_version"
 version = "0.4.0"
@@ -3812,6 +3997,15 @@ dependencies = [
  "subtle",
 ]
 
+[[package]]
+name = "unreachable"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56"
+dependencies = [
+ "void",
+]
+
 [[package]]
 name = "untrusted"
 version = "0.7.1"
@@ -3879,6 +4073,12 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
 
+[[package]]
+name = "void"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
+
 [[package]]
 name = "waker-fn"
 version = "1.1.0"
@@ -3903,9 +4103,9 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
 
 [[package]]
 name = "wasi"
-version = "0.10.2+wasi-snapshot-preview1"
+version = "0.10.0+wasi-snapshot-preview1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
+checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
 
 [[package]]
 name = "wasi"
diff --git a/Cargo.toml b/Cargo.toml
index fc24a6d0e..44bb99af1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,3 +3,5 @@ members = [
   "aquadoggo",
   "aquadoggo_cli",
 ]
+
+default-members = ["aquadoggo_cli"]
diff --git a/aquadoggo/Cargo.toml b/aquadoggo/Cargo.toml
index 937d2483a..09e488d1d 100644
--- a/aquadoggo/Cargo.toml
+++ b/aquadoggo/Cargo.toml
@@ -15,8 +15,9 @@ edition = "2018"
 
 [dependencies]
 anyhow = "1.0.43"
+arrayvec = "0.5.2"
 async-trait = "0.1.53"
-async-graphql = "3.0.35"
+async-graphql = "3.0.38"
 async-graphql-axum = "3.0.35"
 axum = "0.5.1"
 bamboo-rs-core-ed25519-yasmf = "0.1.1"
@@ -25,10 +26,13 @@ deadqueue = { version = "0.2.2", default-features = false, features = [
 ] }
 directories = "3.0.2"
 envy = "0.4.2"
-futures = "0.3.17"
+graphql_client = "0.10"
 hex = "0.4.3"
 lipmaa-link = "0.2.2"
 log = "0.4.14"
+lru = "0.7.5"
+mockall = "0.11.0"
+mockall_double = "0.3.0"
 openssl-probe = "0.1.4"
 # We can not publish the `aquadoggo` crate yet, since `p2panda-rs` is an
 # unpublished dependency.
@@ -46,6 +50,7 @@ tokio = { version = "1.17.0", features = [
     "macros",
     "net",
     "rt",
+    "rt-multi-thread",
     "sync",
     "time",
 ] }
@@ -53,14 +58,15 @@ tower-http = { version = "0.2.4", default-features = false, features = [
     "cors",
 ] }
 triggered = "0.1.2"
+futures = "0.3.21"
 
 [dev-dependencies]
+hyper = "0.14.17"
+http = "0.2.6"
 reqwest = { version = "0.11.9", default-features = false, features = [
     "json",
     "stream",
 ] }
 rstest = "0.12.0"
 tower-service = "0.3.1"
-hyper = "0.14.17"
-http = "0.2.6"
 tower = "0.4.12"
diff --git a/aquadoggo/README.md b/aquadoggo/README.md
index 0582834f3..58771b673 100644
--- a/aquadoggo/README.md
+++ b/aquadoggo/README.md
@@ -76,6 +76,21 @@ $ cargo add aquadoggo
 
 [`cargo-edit`]: https://github.com/killercup/cargo-edit
 
+## Development
+
+### Regenerate graphql schema
+
+When you update the graphql code you'll need to manually regenerate the graphql schema files.
+
+In the `aquadoggo/aquadoggo` directory:
+
+```sh
+cargo build --bin dump_gql_schema
+cargo run --bin dump_gql_schema > src/graphql/replication/client/schema.graphql
+```
+
+Note that the `cargo build` step is required first because the `>` in the run step truncates the schema file which is used for code generation.
+
 ## License
 
 GNU Affero General Public License v3.0 [`AGPL-3.0-or-later`](LICENSE)
diff --git a/aquadoggo/src/bin/dump_gql_schema.rs b/aquadoggo/src/bin/dump_gql_schema.rs
new file mode 100644
index 000000000..44196833a
--- /dev/null
+++ b/aquadoggo/src/bin/dump_gql_schema.rs
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use aquadoggo::graphql::{build_root_schema, Context};
+use aquadoggo::{connection_pool, SqlStorage};
+
+#[tokio::main]
+async fn main() {
+    let pool = connection_pool("sqlite::memory:", 1).await.unwrap();
+    let context = Context::new(SqlStorage::new(pool));
+    let schema = build_root_schema(context);
+    let sdl = schema.sdl();
+
+    println!("{sdl}");
+}
diff --git a/aquadoggo/src/context.rs b/aquadoggo/src/context.rs
index 27a2c39ad..de497730d 100644
--- a/aquadoggo/src/context.rs
+++ b/aquadoggo/src/context.rs
@@ -1,28 +1,40 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
+use std::fmt::Debug;
 use std::ops::Deref;
 use std::sync::Arc;
 
 use crate::config::Configuration;
 use crate::db::provider::SqlStorage;
+use crate::graphql::Context as GraphQLContext;
 use crate::graphql::{build_root_schema, RootSchema};
 
 /// Inner data shared across all services.
 pub struct Data {
-    // Node configuration.
+    /// Node configuration.
     pub config: Configuration,
 
     /// Storage provider with database connection pool.
     pub store: SqlStorage,
 
-    /// Static GraphQL schema.
+    /// Root GraphQL schema.
     pub schema: RootSchema,
 }
 
+impl Debug for Data {
+    fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
+        // Omit the the schema field for now, it doesn't implement debug. Some debug info is better than none.
+        fmt.debug_struct("Data")
+            .field("config", &self.config)
+            .field("store", &self.store)
+            .finish()
+    }
+}
+
 impl Data {
-    /// Initialize new data instance with shared database connection pool.
     pub fn new(store: SqlStorage, config: Configuration) -> Self {
-        let schema = build_root_schema(store.clone());
+        let graphql_context = GraphQLContext::new(store.clone());
+        let schema = build_root_schema(graphql_context);
 
         Self {
             config,
@@ -33,6 +45,7 @@ impl Data {
 }
 
 /// Data shared across all services.
+#[derive(Debug)]
 pub struct Context(pub Arc<Data>);
 
 impl Context {
diff --git a/aquadoggo/src/db/provider.rs b/aquadoggo/src/db/provider.rs
index d0a744b4a..8c40bfef8 100644
--- a/aquadoggo/src/db/provider.rs
+++ b/aquadoggo/src/db/provider.rs
@@ -14,12 +14,14 @@ use crate::graphql::client::{
     EntryArgsRequest, EntryArgsResponse, PublishEntryRequest, PublishEntryResponse,
 };
 
-#[derive(Clone)]
+#[derive(Debug, Clone)]
+/// Sql based storage that implements `StorageProvider`
 pub struct SqlStorage {
     pub(crate) pool: Pool,
 }
 
 impl SqlStorage {
+    /// Create a new `SqlStorage` using the provided db `Pool`
     pub fn new(pool: Pool) -> Self {
         Self { pool }
     }
diff --git a/aquadoggo/src/db/stores/log.rs b/aquadoggo/src/db/stores/log.rs
index 5481ad432..f7364e19b 100644
--- a/aquadoggo/src/db/stores/log.rs
+++ b/aquadoggo/src/db/stores/log.rs
@@ -18,6 +18,7 @@ use crate::db::provider::SqlStorage;
 /// this data according to what it sees in the newly incoming entries.
 ///
 /// `StorageLog` implements the trait `AsStorageLog` which is required when defining a `LogStore`.
+#[derive(Debug)]
 pub struct StorageLog {
     author: Author,
     log_id: LogId,
diff --git a/aquadoggo/src/graphql/api.rs b/aquadoggo/src/graphql/api.rs
index 1ab22893b..f4523b8ac 100644
--- a/aquadoggo/src/graphql/api.rs
+++ b/aquadoggo/src/graphql/api.rs
@@ -7,10 +7,12 @@ use axum::response::{self, IntoResponse};
 
 use crate::context::Context;
 
-pub async fn handle_graphql_playground() -> impl IntoResponse {
-    response::Html(playground_source(GraphQLPlaygroundConfig::new("/")))
+/// Handle graphql playground requests at the given path
+pub async fn handle_graphql_playground(path: &str) -> impl IntoResponse {
+    response::Html(playground_source(GraphQLPlaygroundConfig::new(path)))
 }
 
+/// Handle graphql requests
 pub async fn handle_graphql_query(
     request: GraphQLRequest,
     Extension(context): Extension<Context>,
diff --git a/aquadoggo/src/graphql/client/request.rs b/aquadoggo/src/graphql/client/request.rs
index f026770dc..a7fd21301 100644
--- a/aquadoggo/src/graphql/client/request.rs
+++ b/aquadoggo/src/graphql/client/request.rs
@@ -14,7 +14,9 @@ use p2panda_rs::operation::OperationEncoded;
 /// Request body of `panda_getEntryArguments`.
 #[derive(Deserialize, Debug)]
 pub struct EntryArgsRequest {
+    /// The entry author
     pub author: Author,
+    /// The entry document
     pub document: Option<DocumentId>,
 }
 
@@ -50,7 +52,9 @@ impl Validate for EntryArgsRequest {
 #[derive(Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 pub struct PublishEntryRequest {
+    /// The encoded entry
     pub entry_encoded: EntrySigned,
+    /// The encoded operation
     pub operation_encoded: OperationEncoded,
 }
 
diff --git a/aquadoggo/src/graphql/client/response.rs b/aquadoggo/src/graphql/client/response.rs
index 7b5538939..3481ca33f 100644
--- a/aquadoggo/src/graphql/client/response.rs
+++ b/aquadoggo/src/graphql/client/response.rs
@@ -15,14 +15,18 @@ use crate::db::models::EntryRow;
 #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub struct EntryArgsResponse {
+    /// The log id of the entry
     #[serde(with = "super::u64_string::log_id_string_serialisation")]
     pub log_id: LogId,
 
+    /// The sequence number of the entry
     #[serde(with = "super::u64_string::seq_num_string_serialisation")]
     pub seq_num: SeqNum,
 
+    /// The hash of the entry backlink
     pub backlink: Option<Hash>,
 
+    /// The hash of the entry skiplink
     pub skiplink: Option<Hash>,
 }
 
@@ -64,14 +68,18 @@ impl AsEntryArgsResponse for EntryArgsResponse {
 #[derive(Serialize, Deserialize, Debug, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub struct PublishEntryResponse {
+    /// The log id of the entry
     #[serde(with = "super::u64_string::log_id_string_serialisation")]
     pub log_id: LogId,
 
+    /// The sequence number of the entry
     #[serde(with = "super::u64_string::seq_num_string_serialisation")]
     pub seq_num: SeqNum,
 
+    /// The optional hash of the backlink
     pub backlink: Option<Hash>,
 
+    /// The optional hash of the skiplink
     pub skiplink: Option<Hash>,
 }
 
diff --git a/aquadoggo/src/graphql/context.rs b/aquadoggo/src/graphql/context.rs
new file mode 100644
index 000000000..d41fe542f
--- /dev/null
+++ b/aquadoggo/src/graphql/context.rs
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::sync::Arc;
+
+use tokio::sync::Mutex;
+
+use crate::db::provider::SqlStorage;
+
+use super::replication::context::Context as ReplicationContext;
+
+#[derive(Debug)]
+/// The combined graphql contexts used by the various sub modules
+pub struct Context {
+    /// The replication context
+    pub replication_context: Arc<Mutex<ReplicationContext<SqlStorage>>>,
+    /// The db connection pool used by the client
+    pub store: SqlStorage,
+}
+
+impl Context {
+    /// Create a new Context
+    pub fn new(store: SqlStorage) -> Self {
+        let replication_context =
+            Arc::new(Mutex::new(ReplicationContext::new(1000, store.clone())));
+
+        Self {
+            replication_context,
+            store,
+        }
+    }
+}
diff --git a/aquadoggo/src/graphql/mod.rs b/aquadoggo/src/graphql/mod.rs
index 8c56d6ebf..034b9e170 100644
--- a/aquadoggo/src/graphql/mod.rs
+++ b/aquadoggo/src/graphql/mod.rs
@@ -1,8 +1,14 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
 mod api;
+/// Graphql types for clients
 pub mod client;
+mod context;
+mod replication;
 mod schema;
 
 pub use api::{handle_graphql_playground, handle_graphql_query};
-pub use schema::{build_root_schema, RootSchema};
+pub use client::Query as ClientRoot;
+pub use context::Context;
+pub use replication::ReplicationRoot;
+pub use schema::{build_root_schema, QueryRoot, RootSchema};
diff --git a/aquadoggo/src/graphql/replication/aliased_author.rs b/aquadoggo/src/graphql/replication/aliased_author.rs
new file mode 100644
index 000000000..4dd08ddca
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/aliased_author.rs
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::*;
+
+use super::public_key::PublicKey;
+
+/// AliasedAuthor is one of either the public_key or an alias
+///
+/// The intention of this is to reduce bandwidth when making requests by using a short "alias"
+/// rather than the full author public_key
+///
+/// To get an alias of an author, use the `author_aliases` method which will return this type.
+///
+/// When using as an input to a query, exactly one of public_key or alias must be set otherwise it is an error.
+#[derive(Debug, InputObject, SimpleObject)]
+pub struct AliasedAuthor {
+    /// The author's public key
+    pub public_key: PublicKey,
+    /// The author alias
+    pub alias: ID,
+}
diff --git a/aquadoggo/src/graphql/replication/author.rs b/aquadoggo/src/graphql/replication/author.rs
new file mode 100644
index 000000000..888533881
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/author.rs
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::convert::TryFrom;
+
+use anyhow::{anyhow, Error};
+use async_graphql::*;
+
+use super::public_key::PublicKey;
+
+/// Either the `public_key` or the `alias` of that author.
+#[derive(Debug, InputObject)]
+pub struct Author {
+    /// The author's public key
+    pub public_key: Option<PublicKey>,
+    /// The author alias
+    pub alias: Option<ID>,
+}
+
+#[derive(Debug)]
+pub enum AuthorOrAlias {
+    PublicKey(PublicKey),
+    Alias(ID),
+}
+
+impl TryFrom<Author> for AuthorOrAlias {
+    type Error = Error;
+
+    fn try_from(author: Author) -> Result<Self, Self::Error> {
+        match (author.public_key, author.alias) {
+            (Some(key), None) => Ok(AuthorOrAlias::PublicKey(key)),
+            (None, Some(alias)) => Ok(AuthorOrAlias::Alias(alias)),
+            _ => Err(anyhow!(
+                "Author must have either publicKey or alias set, but not both"
+            )),
+        }
+    }
+}
diff --git a/aquadoggo/src/graphql/replication/client/mod.rs b/aquadoggo/src/graphql/replication/client/mod.rs
new file mode 100644
index 000000000..d5832155a
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/client/mod.rs
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use graphql_client::GraphQLQuery;
+
+use super::Entry;
+use super::EntryHash;
+
+// The paths are relative to the directory where your `Cargo.toml` is located.
+// Both json and the GraphQL schema language are supported as sources for the schema
+#[derive(GraphQLQuery)]
+#[graphql(
+    schema_path = "src/graphql/replication/client/schema.graphql",
+    query_path = "src/graphql/replication/client/queries/get_entry_by_hash.graphql"
+)]
+pub struct GetEntryByHash;
diff --git a/aquadoggo/src/graphql/replication/client/queries/get_entry_by_hash.graphql b/aquadoggo/src/graphql/replication/client/queries/get_entry_by_hash.graphql
new file mode 100644
index 000000000..b8e510d2d
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/client/queries/get_entry_by_hash.graphql
@@ -0,0 +1,6 @@
+query GetEntryByHash($hash: EntryHash) {
+    entryByHash(hash: $hash){
+      entry
+    }
+}
+
diff --git a/aquadoggo/src/graphql/replication/client/schema.graphql b/aquadoggo/src/graphql/replication/client/schema.graphql
new file mode 100644
index 000000000..d9cc861ae
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/client/schema.graphql
@@ -0,0 +1,165 @@
+"""
+AliasedAuthor is one of either the public_key or an alias
+
+The intention of this is to reduce bandwidth when making requests by using a short "alias"
+rather than the full author public_key
+
+To get an alias of an author, use the `author_aliases` method which will return this type.
+
+When using as an input to a query, exactly one of public_key or alias must be set otherwise it is an error.
+"""
+type AliasedAuthor {
+	"""
+	The author's public key
+	"""
+	publicKey: PublicKey!
+	"""
+	The author alias
+	"""
+	alias: ID!
+}
+"""
+Either the `public_key` or the `alias` of that author.
+"""
+input Author {
+	"""
+	The author's public key
+	"""
+	publicKey: PublicKey
+	"""
+	The author alias
+	"""
+	alias: ID
+}
+scalar Entry
+"""
+An entry with an optional payload
+"""
+type EntryAndPayload {
+	"""
+	Get the entry
+	"""
+	entry: Entry!
+	"""
+	Get the payload
+	"""
+	payload: Payload
+}
+type EntryAndPayloadConnection {
+	"""
+	Information to aid in pagination.
+	"""
+	pageInfo: PageInfo!
+	"""
+	A list of edges.
+	"""
+	edges: [EntryAndPayloadEdge]
+}
+"""
+An edge in a connection.
+"""
+type EntryAndPayloadEdge {
+	"""
+	The item at the end of the edge
+	"""
+	node: EntryAndPayload!
+	"""
+	A cursor for use in pagination
+	"""
+	cursor: String!
+}
+type EntryArgsResponse {
+	logId: String!
+	seqNum: String!
+	backlink: String
+	skiplink: String
+}
+scalar EntryHash
+scalar LogId
+"""
+All of the graphql mutation sub modules merged into one top level root
+"""
+type MutationRoot {
+	"""
+	Publish an entry using parameters obtained through `nextEntryArgs` query.
+	
+	Returns arguments for publishing the next entry in the same log.
+	"""
+	publishEntry(entryEncoded: String!, operationEncoded: String!): PublishEntryResponse!
+}
+"""
+Information about pagination in a connection
+"""
+type PageInfo {
+	"""
+	When paginating backwards, are there more items?
+	"""
+	hasPreviousPage: Boolean!
+	"""
+	When paginating forwards, are there more items?
+	"""
+	hasNextPage: Boolean!
+	"""
+	When paginating backwards, the cursor to continue.
+	"""
+	startCursor: String
+	"""
+	When paginating forwards, the cursor to continue.
+	"""
+	endCursor: String
+}
+scalar Payload
+scalar PublicKey
+type PublishEntryResponse {
+	logId: String!
+	seqNum: String!
+	backlink: String
+	skiplink: String
+}
+"""
+All of the graphql query sub modules merged into one top level root
+"""
+type QueryRoot {
+	"""
+	Get an entry by its hash
+	"""
+	entryByHash(hash: EntryHash!): SingleEntryAndPayload
+	"""
+	Get any entries that are newer than the provided sequence_number for a given author and
+	log_id
+	"""
+	getEntriesNewerThanSeq(logId: LogId!, author: Author!, sequenceNumber: SequenceNumber!, first: Int, after: String): EntryAndPayloadConnection!
+	"""
+	Get a single entry by its log_id, sequence_number and author
+	"""
+	entryByLogIdAndSequence(logId: LogId!, sequenceNumber: SequenceNumber!, author: Author!): SingleEntryAndPayload
+	"""
+	Get aliases of the provided `public_keys` that you can use in future requests to save
+	bandwidth.
+	"""
+	authorAliases(publicKeys: [PublicKey!]!): [AliasedAuthor!]!
+	"""
+	Return required arguments for publishing the next entry.
+	"""
+	nextEntryArgs(publicKey: String!, documentId: String): EntryArgsResponse!
+}
+scalar SequenceNumber
+type SingleEntryAndPayload {
+	"""
+	The entry
+	"""
+	entry: Entry!
+	"""
+	The payload
+	"""
+	payload: Payload
+	"""
+	Get the certificate pool for this entry that can be used to verify the entry is valid
+	"""
+	certificatePool: [Entry!]!
+}
+schema {
+	query: QueryRoot
+	mutation: MutationRoot
+}
+
diff --git a/aquadoggo/src/graphql/replication/context.rs b/aquadoggo/src/graphql/replication/context.rs
new file mode 100644
index 000000000..6ad827733
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/context.rs
@@ -0,0 +1,199 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use anyhow::{anyhow, Result};
+use async_graphql::ID;
+use lru::LruCache;
+use mockall::automock;
+use p2panda_rs::entry::decode_entry;
+use p2panda_rs::storage_provider::traits::EntryStore as EntryStoreTrait;
+
+use crate::db::stores::StorageEntry;
+
+pub use super::aliased_author::AliasedAuthor;
+pub use super::public_key::PublicKey;
+use super::AuthorOrAlias;
+use super::Entry;
+use super::EntryAndPayload;
+use super::EntryHash;
+use super::LogId;
+use super::SequenceNumber;
+use super::SingleEntryAndPayload;
+
+#[derive(Debug)]
+pub struct Context<EntryStore: 'static + EntryStoreTrait<StorageEntry>> {
+    author_aliases: LruCache<ID, PublicKey>,
+    next_alias: usize,
+    entry_store: EntryStore,
+}
+
+#[automock]
+#[allow(dead_code)]
+impl<EntryStore: 'static + EntryStoreTrait<StorageEntry>> Context<EntryStore> {
+    pub fn new(author_aliases_cache_size: usize, entry_store: EntryStore) -> Self {
+        Self {
+            author_aliases: LruCache::new(author_aliases_cache_size),
+            next_alias: Default::default(),
+            entry_store,
+        }
+    }
+
+    pub fn insert_author_aliases(&mut self, public_keys: Vec<PublicKey>) -> Vec<AliasedAuthor> {
+        public_keys
+            .into_iter()
+            .map(|public_key| {
+                self.next_alias += 1;
+                self.author_aliases
+                    .put(ID(self.next_alias.to_string()), public_key.clone());
+                AliasedAuthor {
+                    public_key,
+                    alias: self.next_alias.into(),
+                }
+            })
+            .collect()
+    }
+
+    pub fn author_aliases_to_public_keys(
+        &mut self,
+        ids: Vec<ID>,
+    ) -> Result<Vec<Option<PublicKey>>> {
+        ids.into_iter()
+            .map(|id| {
+                let result = self.author_aliases.get(&id).cloned();
+                Ok(result)
+            })
+            .collect()
+    }
+
+    pub async fn entry_by_log_id_and_sequence<'a>(
+        &mut self,
+        log_id: LogId,
+        sequence_number: SequenceNumber,
+        author_alias: AuthorOrAlias,
+    ) -> Result<Option<SingleEntryAndPayload>> {
+        let author = self.get_author(author_alias)?;
+
+        let result = self
+            .entry_store
+            .get_entry_at_seq_num(&author.0, &log_id.0, &sequence_number.0)
+            .await?
+            .map(|entry| entry.into());
+
+        Ok(result)
+    }
+
+    pub async fn entry_by_hash<'a>(
+        &mut self,
+        hash: EntryHash,
+    ) -> Result<Option<SingleEntryAndPayload>> {
+        let result = self
+            .entry_store
+            .get_entry_by_hash(&hash.into())
+            .await?
+            .map(|entry_row| entry_row.into());
+
+        Ok(result)
+    }
+
+    pub async fn get_skiplinks<'a>(&mut self, entry: &Entry) -> Result<Vec<Entry>> {
+        let author = entry.as_ref().author();
+        let entry = decode_entry(entry.as_ref(), None)?;
+        let result = self
+            .entry_store
+            .get_certificate_pool(&author, entry.log_id(), entry.seq_num())
+            .await?
+            .into_iter()
+            .map(|entry| entry.entry_signed().clone().into())
+            .collect();
+
+        Ok(result)
+    }
+
+    pub async fn get_entries_newer_than_seq(
+        &mut self,
+        log_id: LogId,
+        author: AuthorOrAlias,
+        sequence_number: SequenceNumber,
+        max_number_of_entries: usize,
+    ) -> Result<Vec<EntryAndPayload>> {
+        let author = self.get_author(author)?;
+        let result = self
+            .entry_store
+            .get_paginated_log_entries(
+                &author.0,
+                &log_id.0,
+                sequence_number.as_ref(),
+                max_number_of_entries,
+            )
+            .await?
+            .into_iter()
+            .map(|entry| entry.into())
+            .collect();
+
+        Ok(result)
+    }
+
+    fn get_author(&mut self, author_alias: AuthorOrAlias) -> Result<PublicKey, anyhow::Error> {
+        let author = match author_alias {
+            AuthorOrAlias::Alias(alias) => self
+                .author_aliases
+                .get(&alias)
+                .ok_or_else(|| {
+                    anyhow!("author alias did not exist, you may need to re-alias your authors")
+                })?
+                .clone(),
+            AuthorOrAlias::PublicKey(public_key) => public_key,
+        };
+        Ok(author)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::convert::TryInto;
+
+    use p2panda_rs::identity::Author;
+
+    use crate::graphql::replication::{
+        Author as GraphQLAuthor, LogId as GraphQLLogId, PublicKey, SequenceNumber,
+    };
+
+    use super::super::testing::MockEntryStore;
+    use super::Context;
+
+    // TODO: test author aliases
+
+    #[tokio::test]
+    async fn entry_by_log_id_and_sequence() {
+        let expected_log_id = 123;
+        let expected_seq_num = 345u64;
+        let expected_author_string =
+            "7cf4f58a2d89e93313f2de99604a814ecea9800cf217b140e9c3a7ba59a5d982".to_string();
+
+        let log_id: GraphQLLogId = expected_log_id.into();
+        let sequence_number: SequenceNumber = expected_seq_num.try_into().unwrap();
+        let author = Author::new(&expected_author_string).unwrap();
+        let author_id = GraphQLAuthor {
+            alias: None,
+            public_key: Some(PublicKey(author)),
+        };
+
+        let mut mock_entry_store = MockEntryStore::new();
+        mock_entry_store
+            .expect_get_entry_at_seq_num()
+            .withf(move |author, log_id, seq_num| {
+                author.as_str() == expected_author_string
+                    && log_id.as_u64() == expected_log_id
+                    && seq_num.as_u64() == expected_seq_num
+            })
+            .times(1)
+            .returning(|_, _, _| Ok(None));
+
+        let mut context = Context::new(1, mock_entry_store);
+
+        let result = context
+            .entry_by_log_id_and_sequence(log_id, sequence_number, author_id.try_into().unwrap())
+            .await;
+
+        assert!(result.is_ok());
+    }
+}
diff --git a/aquadoggo/src/graphql/replication/entry.rs b/aquadoggo/src/graphql/replication/entry.rs
new file mode 100644
index 000000000..561908124
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/entry.rs
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::*;
+use p2panda_rs::entry::EntrySigned as PandaEntry;
+use serde::{Deserialize, Serialize};
+
+/// A p2panda_rs entry encoded as a String
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct Entry(pub PandaEntry);
+
+impl AsRef<PandaEntry> for Entry {
+    fn as_ref(&self) -> &PandaEntry {
+        &self.0
+    }
+}
+
+scalar!(Entry);
+
+impl From<Entry> for Value {
+    fn from(entry: Entry) -> Self {
+        async_graphql::ScalarType::to_value(&entry)
+    }
+}
+
+impl From<PandaEntry> for Entry {
+    fn from(panda_entry: PandaEntry) -> Self {
+        Entry(panda_entry)
+    }
+}
diff --git a/aquadoggo/src/graphql/replication/entry_and_payload.rs b/aquadoggo/src/graphql/replication/entry_and_payload.rs
new file mode 100644
index 000000000..124683a0d
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/entry_and_payload.rs
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::*;
+
+use crate::db::stores::StorageEntry;
+
+use super::payload::Payload;
+use super::Entry;
+
+/// An entry with an optional payload
+#[derive(SimpleObject, Debug)]
+pub struct EntryAndPayload {
+    /// Get the entry
+    pub entry: Entry,
+    /// Get the payload
+    pub payload: Option<Payload>,
+}
+
+impl From<StorageEntry> for EntryAndPayload {
+    fn from(entry_row: StorageEntry) -> Self {
+        let entry = Entry(entry_row.entry_signed().to_owned());
+        let payload = entry_row
+            .operation_encoded()
+            .map(|encoded| Payload(encoded.to_owned()));
+        Self { entry, payload }
+    }
+}
diff --git a/aquadoggo/src/graphql/replication/entry_hash.rs b/aquadoggo/src/graphql/replication/entry_hash.rs
new file mode 100644
index 000000000..2fc1f6dec
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/entry_hash.rs
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::*;
+use p2panda_rs::hash::Hash;
+use serde::{Deserialize, Serialize};
+
+/// The p2panda_rs hash of an entry
+#[derive(Serialize, Deserialize, Clone, Debug)]
+pub struct EntryHash(Hash);
+
+impl From<EntryHash> for Hash {
+    fn from(e: EntryHash) -> Self {
+        e.0
+    }
+}
+
+scalar!(EntryHash);
+
+impl From<EntryHash> for Value {
+    fn from(entry: EntryHash) -> Self {
+        async_graphql::ScalarType::to_value(&entry)
+    }
+}
diff --git a/aquadoggo/src/graphql/replication/log_id.rs b/aquadoggo/src/graphql/replication/log_id.rs
new file mode 100644
index 000000000..a8aed45fb
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/log_id.rs
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::*;
+use p2panda_rs::entry::LogId as PandaLogId;
+use serde::{Deserialize, Serialize};
+
+/// The log id of a bamboo entry
+#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
+pub struct LogId(pub PandaLogId);
+
+impl LogId {
+    pub fn as_u64(&self) -> u64 {
+        self.0.as_u64()
+    }
+}
+
+impl From<u64> for LogId {
+    fn from(n: u64) -> Self {
+        Self(PandaLogId::new(n))
+    }
+}
+
+impl AsRef<PandaLogId> for LogId {
+    fn as_ref(&self) -> &PandaLogId {
+        &self.0
+    }
+}
+
+scalar!(LogId);
diff --git a/aquadoggo/src/graphql/replication/mod.rs b/aquadoggo/src/graphql/replication/mod.rs
new file mode 100644
index 000000000..0029e22ab
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/mod.rs
@@ -0,0 +1,269 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::convert::TryFrom;
+use std::convert::TryInto;
+use std::marker::PhantomData;
+use std::sync::Arc;
+
+use anyhow::Error as AnyhowError;
+use async_graphql::connection::{query, Connection, CursorType, Edge, EmptyFields};
+use async_graphql::Object;
+use async_graphql::*;
+use mockall_double::double;
+use p2panda_rs::entry::decode_entry;
+use p2panda_rs::storage_provider::traits::EntryStore as EntryStoreTrait;
+use tokio::sync::Mutex;
+
+use crate::db::stores::StorageEntry;
+
+pub mod aliased_author;
+pub mod author;
+pub mod client;
+pub mod context;
+pub mod entry;
+pub mod entry_and_payload;
+pub mod entry_hash;
+pub mod log_id;
+pub mod payload;
+pub mod public_key;
+pub mod sequence_number;
+pub mod single_entry_and_payload;
+
+#[cfg(test)]
+mod testing;
+
+pub use aliased_author::AliasedAuthor;
+pub use author::{Author, AuthorOrAlias};
+
+#[double]
+pub use context::Context as ReplicationContext;
+pub use entry::Entry;
+pub use entry_and_payload::EntryAndPayload;
+pub use entry_hash::EntryHash;
+pub use log_id::LogId;
+pub use payload::Payload;
+pub use public_key::PublicKey;
+pub use sequence_number::SequenceNumber;
+pub use single_entry_and_payload::SingleEntryAndPayload;
+
+#[derive(Debug, Default)]
+/// The root graphql object for replication
+pub struct ReplicationRoot<EntryStore> {
+    entry_store: PhantomData<EntryStore>,
+}
+
+impl<EntryStore> ReplicationRoot<EntryStore> {
+    /// Create a new ReplicationRoot
+    pub fn new() -> Self {
+        Self {
+            entry_store: PhantomData::default(),
+        }
+    }
+}
+
+#[Object]
+impl<EntryStore: 'static + EntryStoreTrait<StorageEntry> + Sync + Send>
+    ReplicationRoot<EntryStore>
+{
+    /// Get an entry by its hash
+    async fn entry_by_hash<'a>(
+        &self,
+        ctx: &Context<'a>,
+        hash: EntryHash,
+    ) -> Result<Option<SingleEntryAndPayload>> {
+        let ctx: &Arc<Mutex<ReplicationContext<EntryStore>>> = ctx.data()?;
+
+        let result = ctx.lock().await.entry_by_hash(hash).await?;
+
+        Ok(result)
+    }
+
+    /// Get any entries that are newer than the provided sequence_number for a given author and
+    /// log_id
+    async fn get_entries_newer_than_seq<'a>(
+        &self,
+        ctx: &Context<'a>,
+        log_id: LogId,
+        author: Author,
+        sequence_number: SequenceNumber,
+        first: Option<i32>,
+        after: Option<String>,
+    ) -> Result<Connection<SequenceNumber, EntryAndPayload, EmptyFields, EmptyFields>> {
+        let ctx: &Arc<Mutex<ReplicationContext<EntryStore>>> = ctx.data()?;
+        let author: AuthorOrAlias = author.try_into()?;
+        query(
+            after,
+            None,
+            first,
+            None,
+            |after: Option<SequenceNumber>, _, first, _| async move {
+                // Add the sequence_number to the after cursor to get the starting sequence number.
+                let start: u64 = sequence_number.as_u64() + after.map(|a| a.as_u64()).unwrap_or(0);
+                let start_sequence = SequenceNumber::new(start)?;
+
+                // Limit the maximum number of entries to 10k, set a default value of 10
+                let max_number_of_entries = first.map(|n| n.clamp(0, 10000)).unwrap_or(10);
+
+                let edges = ctx
+                    .lock()
+                    .await
+                    .get_entries_newer_than_seq(
+                        log_id,
+                        author,
+                        start_sequence,
+                        max_number_of_entries,
+                    )
+                    .await?
+                    .into_iter()
+                    .map(|entry| {
+                        let decoded = decode_entry(entry.entry.as_ref(), None).unwrap();
+                        let sequence_number = SequenceNumber(*decoded.seq_num());
+                        Edge::new(sequence_number, entry)
+                    })
+                    .collect::<Vec<_>>();
+
+                let has_next_page = edges.len() == max_number_of_entries;
+                let mut connection = Connection::new(false, has_next_page);
+
+                connection.append(edges);
+
+                Result::<_, Error>::Ok(connection)
+            },
+        )
+        .await
+    }
+
+    /// Get a single entry by its log_id, sequence_number and author
+    async fn entry_by_log_id_and_sequence<'a>(
+        &self,
+        ctx: &Context<'a>,
+        log_id: LogId,
+        sequence_number: SequenceNumber,
+        author: Author,
+    ) -> Result<Option<SingleEntryAndPayload>> {
+        let ctx: &Arc<Mutex<ReplicationContext<EntryStore>>> = ctx.data()?;
+        let author: AuthorOrAlias = author.try_into()?;
+        let result = ctx
+            .lock()
+            .await
+            .entry_by_log_id_and_sequence(log_id, sequence_number, author)
+            .await?;
+
+        Ok(result)
+    }
+
+    /// Get aliases of the provided `public_keys` that you can use in future requests to save
+    /// bandwidth.
+    // Maybe this should be a mutation
+    async fn author_aliases<'a>(
+        &self,
+        ctx: &Context<'a>,
+        public_keys: Vec<PublicKey>,
+    ) -> Result<Vec<AliasedAuthor>> {
+        let ctx: &Arc<Mutex<ReplicationContext<EntryStore>>> = ctx.data()?;
+        let result = ctx.lock().await.insert_author_aliases(public_keys);
+
+        Ok(result)
+    }
+}
+
+impl CursorType for SequenceNumber {
+    type Error = AnyhowError;
+
+    fn decode_cursor(s: &str) -> Result<Self, Self::Error> {
+        let num: u64 = s.parse()?;
+        let result = SequenceNumber::try_from(num)?;
+        Ok(result)
+    }
+
+    fn encode_cursor(&self) -> String {
+        self.as_u64().to_string()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::convert::TryFrom;
+    use std::sync::Arc;
+
+    use async_graphql::{
+        connection::CursorType, EmptyMutation, EmptySubscription, Request, Schema,
+    };
+    use tokio::sync::Mutex;
+
+    use super::testing::MockEntryStore;
+    use super::{AuthorOrAlias, ReplicationContext, ReplicationRoot, SequenceNumber};
+
+    #[tokio::test]
+    async fn get_entries_newer_than_seq_cursor_addition_is_ok() {
+        // Main point of this test is make sure the cursor + sequence_number logic addition is
+        // correct.
+        let log_id = 3u64;
+        let sequence_number = 123u64;
+        let author_string =
+            "7cf4f58a2d89e93313f2de99604a814ecea9800cf217b140e9c3a7ba59a5d982".to_string();
+        let after = SequenceNumber::try_from(sequence_number).unwrap();
+        let first = 5;
+        let expected_start = sequence_number + after.as_u64();
+
+        let gql_query = format!(
+            "
+        query{{
+          getEntriesNewerThanSeq(logId: {}, author: {{publicKey: \"{}\" }}, sequenceNumber:{}, first: {}, after: \"{}\" ){{
+            pageInfo {{
+              hasNextPage
+            }}
+          }}
+        }}",
+            log_id, author_string, sequence_number, first, after.encode_cursor()
+        );
+
+        let mut replication_context: ReplicationContext<MockEntryStore> =
+            ReplicationContext::default();
+
+        // Prepare our main assertions.
+        // - Checks that get_entries_newer_than_seq is called with the values we expect
+        // - Checks that get_entries_newer_than_seq is called once
+        // - Configures get_entries_newer_than_seq to return an empty Vec
+        replication_context
+            .expect_get_entries_newer_than_seq()
+            .withf({
+                let author_string = author_string.clone();
+
+                move |log_id_, author_, sequence_number_, max_number_of_entries_| {
+                    let author_matches = match author_ {
+                        AuthorOrAlias::PublicKey(public_key) => {
+                            public_key.0.as_str() == author_string
+                        }
+                        _ => false,
+                    };
+                    sequence_number_.as_u64() == expected_start
+                        && log_id_.as_u64() == log_id
+                        && author_matches
+                        && *max_number_of_entries_ == first
+                }
+            })
+            .returning(|_, _, _, _| Ok(vec![]))
+            .once();
+
+        // Build up a schema with our mocks that can handle gql query strings
+        let replication_root = ReplicationRoot::<MockEntryStore>::new();
+        let schema = Schema::build(replication_root, EmptyMutation, EmptySubscription)
+            .data(Arc::new(Mutex::new(replication_context)))
+            .finish();
+
+        // Act
+        let result = schema.execute(Request::new(gql_query)).await;
+
+        // Assert
+
+        // Check that we get the Ok returned from get_entries_newer_than_seq
+        assert!(result.is_ok());
+
+        // There should not be a next page because we returned an empty vec from
+        // get_entries_newer_than_seq and it's length is not == `first` == 5;
+        let json_value = result.data.into_json().unwrap();
+        let has_next_page = &json_value["getEntriesNewerThanSeq"]["pageInfo"]["hasNextPage"];
+        assert!(!has_next_page.as_bool().unwrap());
+    }
+}
diff --git a/aquadoggo/src/graphql/replication/payload.rs b/aquadoggo/src/graphql/replication/payload.rs
new file mode 100644
index 000000000..0e31f8ca9
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/payload.rs
@@ -0,0 +1,11 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::*;
+use p2panda_rs::operation::OperationEncoded;
+use serde::{Deserialize, Serialize};
+
+/// The payload of an entry
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Payload(pub OperationEncoded);
+
+scalar!(Payload);
diff --git a/aquadoggo/src/graphql/replication/public_key.rs b/aquadoggo/src/graphql/replication/public_key.rs
new file mode 100644
index 000000000..78aabf77b
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/public_key.rs
@@ -0,0 +1,11 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::*;
+use p2panda_rs::identity::Author;
+use serde::{Deserialize, Serialize};
+
+/// The public key of an entry
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct PublicKey(pub Author);
+
+scalar!(PublicKey);
diff --git a/aquadoggo/src/graphql/replication/sequence_number.rs b/aquadoggo/src/graphql/replication/sequence_number.rs
new file mode 100644
index 000000000..8d6cfc5f8
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/sequence_number.rs
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::convert::TryFrom;
+
+use anyhow::Result;
+use async_graphql::*;
+use p2panda_rs::entry::{SeqNum as PandaSeqNum, SeqNumError};
+use serde::{Deserialize, Serialize};
+
+/// The sequence number of an entry
+#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
+pub struct SequenceNumber(pub PandaSeqNum);
+
+impl SequenceNumber {
+    pub fn new(seq: u64) -> Result<Self> {
+        let panda_seq_num = PandaSeqNum::new(seq)?;
+        Ok(Self(panda_seq_num))
+    }
+}
+
+impl SequenceNumber {
+    pub fn as_u64(self) -> u64 {
+        self.0.as_u64()
+    }
+}
+
+impl AsRef<PandaSeqNum> for SequenceNumber {
+    fn as_ref(&self) -> &PandaSeqNum {
+        &self.0
+    }
+}
+
+impl TryFrom<u64> for SequenceNumber {
+    type Error = SeqNumError;
+
+    fn try_from(value: u64) -> Result<Self, Self::Error> {
+        let seq_num = PandaSeqNum::new(value)?;
+        Ok(Self(seq_num))
+    }
+}
+
+scalar!(SequenceNumber);
diff --git a/aquadoggo/src/graphql/replication/single_entry_and_payload.rs b/aquadoggo/src/graphql/replication/single_entry_and_payload.rs
new file mode 100644
index 000000000..ca6483dd8
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/single_entry_and_payload.rs
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use async_graphql::*;
+
+use crate::db::stores::StorageEntry;
+use crate::graphql::Context as GraphQLContext;
+
+use super::payload::Payload;
+use super::Entry;
+
+/// A p2panda entry with optional payload and the certificate pool required to verify it.
+#[derive(Debug)]
+pub struct SingleEntryAndPayload {
+    pub entry: Entry,
+    pub payload: Option<Payload>,
+}
+
+#[Object]
+impl SingleEntryAndPayload {
+    /// The entry
+    async fn entry(&self) -> &Entry {
+        &self.entry
+    }
+
+    /// The payload
+    async fn payload(&self) -> Option<&Payload> {
+        self.payload.as_ref()
+    }
+
+    /// Get the certificate pool for this entry that can be used to verify the entry is valid
+    async fn certificate_pool<'a>(&self, ctx: &Context<'a>) -> Result<Vec<Entry>> {
+        let ctx: &GraphQLContext = ctx.data()?;
+
+        let result = ctx
+            .replication_context
+            .lock()
+            .await
+            .get_skiplinks(&self.entry)
+            .await?;
+
+        Ok(result)
+    }
+}
+
+impl From<StorageEntry> for SingleEntryAndPayload {
+    fn from(entry_and_payload: StorageEntry) -> Self {
+        let entry = Entry(entry_and_payload.entry_signed().to_owned());
+        let payload = entry_and_payload
+            .operation_encoded()
+            .map(|encoded| Payload(encoded.to_owned()));
+        Self { entry, payload }
+    }
+}
diff --git a/aquadoggo/src/graphql/schema.rs b/aquadoggo/src/graphql/schema.rs
index ef7c1202e..d9dd189f8 100644
--- a/aquadoggo/src/graphql/schema.rs
+++ b/aquadoggo/src/graphql/schema.rs
@@ -1,18 +1,36 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-use async_graphql::{EmptySubscription, Schema};
+use async_graphql::{EmptySubscription, MergedObject, Schema};
 
 use crate::db::provider::SqlStorage;
-use crate::graphql::client::{Mutation, Query};
+
+use super::client::{Mutation as ClientMutationRoot, Query as ClientQueryRoot};
+use super::replication::ReplicationRoot;
+use super::Context;
+
+/// All of the graphql query sub modules merged into one top level root
+#[derive(MergedObject, Debug)]
+pub struct QueryRoot(pub ReplicationRoot<SqlStorage>, pub ClientQueryRoot);
+
+/// All of the graphql mutation sub modules merged into one top level root
+#[derive(MergedObject, Debug, Copy, Clone, Default)]
+pub struct MutationRoot(pub ClientMutationRoot);
 
 /// GraphQL schema for p2panda node.
-pub type RootSchema = Schema<Query, Mutation, EmptySubscription>;
+pub type RootSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
 
-pub fn build_root_schema(store: SqlStorage) -> RootSchema {
-    let query = Query::default();
-    let mutation = Mutation::default();
+/// Build the root graphql schema that can handle graphql requests.
+pub fn build_root_schema(context: Context) -> RootSchema {
+    let replication_root = ReplicationRoot::<SqlStorage>::new();
+    let client_query_root = ClientQueryRoot::default();
+    let query_root = QueryRoot(replication_root, client_query_root);
 
-    Schema::build(query, mutation, EmptySubscription)
-        .data(store)
+    let client_mutation_root = Default::default();
+    let mutation_root = MutationRoot(client_mutation_root);
+    Schema::build(query_root, mutation_root, EmptySubscription)
+        .data(context.replication_context)
+        .data(context.store)
+        // Add more contexts here if you need, eg:
+        //.data(context.ping_context)
         .finish()
 }
diff --git a/aquadoggo/src/lib.rs b/aquadoggo/src/lib.rs
index 718709da8..81e8e86e2 100644
--- a/aquadoggo/src/lib.rs
+++ b/aquadoggo/src/lib.rs
@@ -18,7 +18,8 @@ mod config;
 mod context;
 mod db;
 mod errors;
-mod graphql;
+/// Aquadoggo graphql types for handling client and replication requests
+pub mod graphql;
 mod manager;
 mod materializer;
 mod node;
@@ -28,4 +29,5 @@ mod server;
 mod test_helpers;
 
 pub use config::Configuration;
+pub use db::{connection_pool, provider::SqlStorage};
 pub use node::Node;
diff --git a/aquadoggo/src/materializer/worker.rs b/aquadoggo/src/materializer/worker.rs
index b4b8bc847..166b81637 100644
--- a/aquadoggo/src/materializer/worker.rs
+++ b/aquadoggo/src/materializer/worker.rs
@@ -560,9 +560,10 @@ mod tests {
             let tasks: Vec<Task<JigsawPiece>> = input
                 .relations
                 .iter()
-                .filter_map(|id| match db.pieces.get(&id) {
-                    Some(piece) => Some(Task::new("find", piece.clone())),
-                    None => None,
+                .filter_map(|id| {
+                    db.pieces
+                        .get(id)
+                        .map(|piece| Task::new("find", piece.clone()))
                 })
                 .collect();
 
@@ -585,7 +586,7 @@ mod tests {
 
                 // Add another piece to list of ids. Unwrap as we know the list is not empty.
                 let id = candidates.pop().unwrap();
-                ids.push(id.clone());
+                ids.push(id);
 
                 // Get all related pieces of this piece
                 match db.pieces.get(&id) {
@@ -594,7 +595,7 @@ mod tests {
                             // Check if we have already visited all relations of this piece,
                             // otherwise add them to list
                             if !ids.contains(relation_id) && !candidates.contains(relation_id) {
-                                candidates.push(relation_id.clone());
+                                candidates.push(*relation_id);
                             }
                         }
                     }
@@ -611,7 +612,7 @@ mod tests {
                 //    of them as the future puzzle!
                 if puzzle_id.is_none() {
                     for id in &ids {
-                        if puzzle.piece_ids.contains(&id) {
+                        if puzzle.piece_ids.contains(id) {
                             puzzle_id = Some(puzzle.id);
                         }
                     }
@@ -660,18 +661,18 @@ mod tests {
                 .puzzles
                 .values()
                 .find(|item| item.piece_ids.contains(&input.id) && !item.complete)
-                .map(|item| item.clone());
+                .cloned();
 
             // 2. Check if all piece dependencies are met
             match puzzle {
                 None => Err(TaskError::Failure),
                 Some(mut puzzle) => {
                     for piece_id in &puzzle.piece_ids {
-                        match db.pieces.get(&piece_id) {
+                        match db.pieces.get(piece_id) {
                             None => return Err(TaskError::Failure),
                             Some(piece) => {
                                 for relation_piece_id in &piece.relations {
-                                    if !puzzle.piece_ids.contains(&relation_piece_id) {
+                                    if !puzzle.piece_ids.contains(relation_piece_id) {
                                         return Err(TaskError::Failure);
                                     }
                                 }
@@ -681,7 +682,7 @@ mod tests {
 
                     // Mark puzzle as complete! We are done here!
                     puzzle.complete = true;
-                    db.puzzles.insert(puzzle.id, puzzle.clone());
+                    db.puzzles.insert(puzzle.id, puzzle);
                     Ok(None)
                 }
             }
@@ -730,7 +731,7 @@ mod tests {
                 for _ in 0..size {
                     let mut relations: Vec<usize> = Vec::new();
 
-                    id = id + 1;
+                    id += 1;
 
                     if id % size != 0 {
                         // Add related piece to the right
@@ -759,7 +760,7 @@ mod tests {
                 }
             }
 
-            offset = offset + (size * size);
+            offset += size * size;
         }
 
         // Mix all puzzle pieces to a large chaotic pile
@@ -781,7 +782,7 @@ mod tests {
             .puzzles
             .values()
             .filter(|puzzle| puzzle.complete)
-            .map(|puzzle| puzzle.clone())
+            .cloned()
             .collect();
         assert_eq!(completed.len(), puzzles_count);
     }
diff --git a/aquadoggo/src/server.rs b/aquadoggo/src/server.rs
index 4cf07984b..286404870 100644
--- a/aquadoggo/src/server.rs
+++ b/aquadoggo/src/server.rs
@@ -14,6 +14,8 @@ use crate::context::Context;
 use crate::graphql::{handle_graphql_playground, handle_graphql_query};
 use crate::manager::Shutdown;
 
+const GRAPHQL_ROUTE: &str = "/graphql";
+
 /// Build HTTP server with GraphQL API.
 pub fn build_server(context: Context) -> Router {
     // Configure CORS middleware
@@ -25,8 +27,8 @@ pub fn build_server(context: Context) -> Router {
     Router::new()
         // Add GraphQL routes
         .route(
-            "/graphql",
-            get(handle_graphql_playground).post(handle_graphql_query),
+            GRAPHQL_ROUTE,
+            get(|| handle_graphql_playground(GRAPHQL_ROUTE)).post(handle_graphql_query),
         )
         // Add middlewares
         .layer(cors)


Test patch:
diff --git a/aquadoggo/src/graphql/replication/testing/mod.rs b/aquadoggo/src/graphql/replication/testing/mod.rs
new file mode 100644
index 000000000..08ea673a8
--- /dev/null
+++ b/aquadoggo/src/graphql/replication/testing/mod.rs
@@ -0,0 +1,58 @@
+use async_trait::async_trait;
+use mockall::mock;
+
+use p2panda_rs::entry::{LogId, SeqNum};
+use p2panda_rs::hash::Hash;
+use p2panda_rs::identity::Author;
+use p2panda_rs::schema::SchemaId;
+use p2panda_rs::storage_provider::errors::EntryStorageError;
+use p2panda_rs::storage_provider::traits::EntryStore;
+
+use crate::db::stores::StorageEntry;
+
+mock! {
+    pub EntryStore {}
+    #[async_trait]
+    impl EntryStore<StorageEntry> for EntryStore {
+        async fn insert_entry(&self, value: StorageEntry) -> Result<(), EntryStorageError>;
+
+        async fn get_entry_at_seq_num(
+            &self,
+            author: &Author,
+            log_id: &LogId,
+            seq_num: &SeqNum,
+            ) -> Result<Option<StorageEntry>, EntryStorageError>;
+
+        async fn get_entry_by_hash(
+            &self,
+            hash: &Hash,
+            ) -> Result<Option<StorageEntry>, EntryStorageError>;
+
+        async fn get_latest_entry(
+            &self,
+            author: &Author,
+            log_id: &LogId,
+            ) -> Result<Option<StorageEntry>, EntryStorageError>;
+
+        async fn get_entries_by_schema(
+            &self,
+            schema: &SchemaId,
+            ) -> Result<Vec<StorageEntry>, EntryStorageError>;
+
+        async fn get_paginated_log_entries(
+            &self,
+            author: &Author,
+            log_id: &LogId,
+            seq_num: &SeqNum,
+            max_number_of_entries: usize,
+            ) -> Result<Vec<StorageEntry>, EntryStorageError>;
+
+        async fn get_certificate_pool(
+            &self,
+            author_id: &Author,
+            log_id: &LogId,
+            seq_num: &SeqNum,
+            ) -> Result<Vec<StorageEntry>, EntryStorageError>;
+
+    }
+}