What is Ankurah?
Ankurah is a state-management framework that enables real-time data synchronization across multiple nodes with built-in observability.
It supports multiple storage and data type backends to enable no-compromise representation of your data.
Note: This project is in the early stages of development, and is not yet ready for production use.
Key Features
- Schema-First Design: Define data models using Rust structs with an ActiveRecord-style interface - View/Mutable
- Content-filtered pub/sub: Subscribe to changes on a collection using a SQL-like query
- Real-Time Observability: Signal-based pattern for tracking entity changes
- Distributed Architecture: Multi-node synchronization with event sourcing
- Flexible Storage: Support for multiple storage backends (Sled, Postgres, TiKV)
- Isomorphic code: Server applications and Web applications use the same code, including first-class support for React and Leptos out of the box
Core Concepts
- Model: A struct describing fields and types for entities in a collection (data binding)
- Collection: A group of entities of the same type (similar to a database table, and backed by a table in the postgres backend)
- Entity: A discrete identity in a collection - Dynamic schema (similar to a schema-less database row)
- View: A read-only representation of an entity - Typed by the model
- Mutable: A mutable state representation of an entity - Typed by the model
- Event: An atomic change that can be applied to an entity - used for synchronization and audit trail
Design Philosophy
Ankurah follows an event-sourced architecture where:
- All operations have unique IDs and precursor operations
- Entity state is maintained per node with operation tree tracking
- Operations use ULID for distributed ID generation
- Entity IDs are derived from their creation operation
Quick Example
#![allow(unused)] fn main() { // Subscribe to changes on the client let subscription = client.subscribe::<_,_,AlbumView>( "name = 'Origin of Symmetry'", |changes| { println!("Received changes: {}", changes); } ).await?; // Create a new album on the server let trx = server.begin(); let album = trx.create(&Album { name: "Origin of Symmetry".into(), year: "2001".into(), }).await?; trx.commit().await?; }
Community
Join the conversation and contribute:
License
Ankurah is dual-licensed under MIT or Apache-2.0.
Getting Started
There are two ways to get started with Ankurah: using a template or setting up manually.
Starting from a Template
The quickest way to get started is to use our React + Sled template with cargo-generate:
cargo generate https://github.com/ankurah/react-sled-template
This will create a new project with:
- A Rust server using the Sled storage backend
- A React frontend with TypeScript
- WASM bindings pre-configured
- WebSocket communication between client and server
- Example models and UI components
After generating your project:
cd your-project-name
./dev.sh
This starts watchers for the Rust server, wasm-bindings, and React app. Open your browser to http://localhost:5173. Press Ctrl+C to stop and all watchers will exit cleanly.
Tip: More templates will be added soon for different use cases!
Need help? Join the Ankurah Discord!
Manual Setup
If you want to set up Ankurah from scratch, follow these steps:
Prerequisites
-
Install Rust:
-
Install Cargo Watch (useful for development workflow):
cargo install cargo-watch -
Install wasm-pack:
-
Install Bun (npm/node might work, but Bun is faster):
Server Setup
Start the example server (keep this running):
cargo run -p ankurah-example-server
Or in development mode with auto-reload:
cargo watch -x 'run -p ankurah-example-server'
React Example App
-
Compile the Wasm Bindings (keep this running):
Navigate to the
wasm-bindingsexample directory:cd examples/wasm-bindings wasm-pack build --target web --debugOr in development mode with auto-rebuild:
cargo watch -s 'wasm-pack build --target web --debug' -
Run the React Example App (keep this running):
cd examples/react-app bun install bun dev -
Test the app:
Load
http://localhost:5173/in one regular browser tab, and one incognito browser tab to see real-time synchronization in action!Note: You can also use two regular browser tabs, but they share one IndexedDB local storage backend, so incognito mode provides a better test of multi-node synchronization.
Leptos Example App
-
Install Trunk (build tool used by Leptos):
cargo install trunk -
Run the Leptos Example App (keep this running):
cd examples/leptos-app trunk serve --open
Note: For the Leptos app, there is no need to build the Wasm bindings crate separately.
How It Works
In the example setup:
- The "server" process is a native Rust process whose node is flagged as "durable", meaning that it attests it will not lose data.
- The "client" process is a WASM process that is also durable in some sense, but not to be relied upon to have all data.
- The demo server currently uses the Sled backend, but Postgres is also supported, and TiKV support is planned.
- WebSocket connections enable real-time bi-directional communication between nodes.
Next Steps
- Check out the Examples page for more code samples
- Learn about the Architecture to understand how Ankurah works
- Read the Glossary to understand key terminology
- Join the Discord to ask questions and share your projects!
Architecture
Ankurah is built on a distributed, event-sourced architecture that enables real-time data synchronization across multiple nodes.
High-Level Overview
The following interactive diagram shows the key components and data flow in an Ankurah system:
Key Architectural Components
Node
A Node is the fundamental unit in Ankurah. Each node can:
- Store data using a pluggable storage backend
- Subscribe to changes from other nodes
- Publish changes to subscribed nodes
- Maintain its own view of entity state
Storage Backends
Ankurah supports multiple storage backends:
- Sled: Embedded key-value store, great for development and embedded applications
- Postgres: Production-grade relational database backend
- IndexedDB (WASM): Browser-based storage for client applications
- TiKV (planned): Distributed transactional key-value database
Event Sourcing
All changes in Ankurah are represented as immutable events:
- Each event has a unique ID (ULID) for distributed generation
- Events reference their precursor events, forming a directed acyclic graph (DAG)
- Entity state is derived from applying events in order
- The "present" state includes the "head" operations of the event tree
Subscriptions
Nodes can subscribe to changes using SQL-like queries:
#![allow(unused)] fn main() { client.subscribe::<_,_,AlbumView>( "name LIKE 'Origin%' AND year > '2000'", |changes| { // Handle matching changes } ).await?; }
The subscription system uses:
- Content filtering: Only matching entities trigger callbacks
- Real-time updates: Changes propagate immediately
- Efficient indexing: Queries are optimized using available indexes
Reactive Runtime
Ankurah includes a reactive runtime (Reactor) that:
- Tracks dependencies between entities
- Propagates changes through the dependency graph
- Enables derived/computed values
- Powers the signal-based observability pattern
Communication Patterns
Client-Server
- WebSocket-based bidirectional communication
- Automatic reconnection and synchronization
- Delta-based updates for efficiency
Peer-to-Peer (Planned)
Future versions will support:
- Direct peer-to-peer connections
- Mesh networking
- Cryptographic identities
- End-to-end encryption
Data Flow
- Create/Update: A node creates or updates an entity
- Event Generation: An immutable event is generated and stored
- Local Application: The event is applied to the local node's state
- Subscription Matching: The reactor checks which subscriptions match
- Propagation: Matching events are sent to subscribed nodes
- Remote Application: Remote nodes receive and apply the event
Consistency Model
Ankurah uses eventual consistency with strong guarantees:
- Operations are causally consistent: if event B depends on event A, all nodes see A before B
- Conflicts are resolved deterministically using operation IDs
- Nodes can operate while partitioned and sync when reconnected
Learn More
- See the Design Goals for the philosophy behind these choices
- Check out Examples for practical code demonstrating these concepts
- Join the Discord to discuss architecture and implementation details
Glossary
This glossary defines key terms and concepts used throughout Ankurah.
Core Concepts
Model
A struct that describes the fields and their types for an entity in a collection. Models define the data binding schema and generate View and Mutable types.
#![allow(unused)] fn main() { #[derive(Model)] struct Album { name: String, year: String, } // Generates: AlbumView, AlbumMutable }
Collection
A collection of entities, with a name and a type that implements the Model trait. Similar to a table in a traditional database. In the Postgres backend, collections are backed by actual database tables.
Entity
A discrete identity in a collection similar to a row in a database. Each entity has a dynamic schema and can have properties bound to it via Models. An entity's ID is derived from the operation that created it.
View
A struct that represents the read-only view of an entity which is typed by the Model. Views provide type-safe access to entity properties without allowing mutations.
#![allow(unused)] fn main() { let album: AlbumView = entity.view()?; println!("Album: {} ({})", album.name, album.year); }
Mutable
A struct that represents the mutable state of an entity which is typed by the Model. Mutables allow type-safe modifications to entity properties.
#![allow(unused)] fn main() { let mut album: AlbumMutable = entity.mutable()?; album.name.set("New Album Name"); }
Event
A single event that may or may not be applied to an entity. Events are immutable operations that form the basis of Ankurah's event sourcing. Each event has:
- A unique ID (ULID)
- References to precursor events
- A payload describing the change
- Metadata (timestamp, node ID, etc.)
Infrastructure
Node
A participant in the Ankurah network. Nodes can be servers, clients, or peers. Each node has:
- A storage backend
- A policy agent (for permissions)
- Connection handlers
- A reactor for subscriptions
Storage Engine
A means of storing and retrieving data which is generally durable (but not necessarily). Available engines:
- Sled: Embedded KV store
- Postgres: Relational database
- IndexedDB: Browser storage (WASM)
- TiKV (planned): Distributed KV store
Storage Collection
A collection of entities in a storage engine. The physical representation of a Collection in the storage layer.
Operations
Transaction
A unit of work that groups multiple operations. Transactions provide:
- Atomicity: All operations succeed or fail together
- Isolation: Operations are isolated from other transactions
- Consistency: Database constraints are maintained
#![allow(unused)] fn main() { let trx = node.begin(); let entity = trx.create(&Album { /* ... */ }).await?; trx.commit().await?; }
Subscription
A live query that receives updates when matching entities change. Subscriptions use SQL-like predicates for filtering.
#![allow(unused)] fn main() { node.subscribe::<_,_,AlbumView>("year > '2000'", |changes| { // Handle changes }).await?; }
Event Sourcing Terms
ULID
Universally Unique Lexicographically Sortable Identifier. Used for operation IDs to enable:
- Distributed ID generation without coordination
- Temporal ordering via lexicographic sorting
- Compact representation (128-bit)
DAG (Directed Acyclic Graph)
The structure formed by events and their precursor relationships. The DAG enables:
- Causal consistency
- Conflict detection
- Efficient synchronization
Lineage
The chain of events that led to an entity's current state. Used for:
- Audit trails
- Conflict resolution
- Replication
Head
The most recent operation(s) in an entity's event DAG. Nodes track heads to determine if they have the latest version.
Reactivity
Signal
An observable value that notifies subscribers when it changes. Ankurah's signal system is inspired by SolidJS and enables reactive UIs.
Reactor
The runtime component that manages subscriptions, tracks dependencies, and propagates changes. The reactor ensures that all live queries and derived values stay up-to-date.
Live Query
A query that automatically updates when the underlying data changes. Implemented using subscriptions and the reactor.
Policy & Security
Policy Agent
A component that controls access to operations. Agents decide:
- Can a node read an entity?
- Can a node modify an entity?
- Can a node subscribe to a collection?
Context
A wrapper around a Node that includes user/session information (ContextData). Operations performed through a Context are subject to policy checks.
#![allow(unused)] fn main() { let context = node.context(user_data)?; let album = context.create(&Album { /* ... */ }).await?; }
Additional Resources
- See What is Ankurah? for a high-level overview
- Check Architecture for how these concepts fit together
- Visit Examples for practical usage
Design Goals
Ankurah is designed with specific goals in mind to create a powerful, flexible, and developer-friendly state management framework.
Schema / UX
Model-Based Schema Definition
- Define schema using "Model" structs, which define the data types for a collection of entities
- An ActiveRecord style interface with type-specific methods for each value
- TypeScript/JavaScript bindings allow these Model definitions to be used client or serverside
- Macros to create and query entities in the collection
Example:
#![allow(unused)] fn main() { #[derive(Model)] struct Album { name: String, year: String, artist: String, } // Use it let album = context.create(&Album { name: "Origin of Symmetry".into(), year: "2001".into(), artist: "Muse".into(), }).await?; }
Observability
Signal-Style Reactive Pattern
- Utilize a "signal" style pattern to allow for observability of changes to entities, collections, and values
- Derivative signals can be created which filter, combine, and transform those changes
- React bindings are a key consideration
- Leptos and other Rust web frameworks should also work, but are lower priority initially
Benefits:
- Automatic UI updates when data changes
- Declarative data dependencies
- Efficient change propagation
Storage and State Management
Multiple Backing Stores
Support for various storage backends:
- Sled KV Store (initial implementation)
- Postgres (production-ready relational database)
- TiKV (planned - distributed transactional KV)
- IndexedDB (browser/WASM support)
- Others as needed
Event Sourcing / Operation-Based
All changes are tracked as immutable operations:
- Audit Trail: All operations have a unique ID and a list of precursor operations
- Immutable History: Operations are immutable (with considerations for CRDT compaction and GDPR)
- Current State: The "present" state of an entity is maintained per node, including the "head" of the operation tree
- Version Tracking: Nodes can determine if they have the latest version of an entity
Operation IDs
- Use ULID (Universally Unique Lexicographically Sortable Identifiers) for distributed ID generation
- Enables lexicographical ordering without coordination
- Entity IDs are derived from the initial operation that created them (genesis operation)
Future Considerations:
- How can this be modified to provide non-adversarial cryptographic collision resistance?
- How can we add adversarial attack resistance?
Development Milestones
Major Milestone 1 - Getting the foot in the door
Core functionality for early adopters:
- ✅ Production-usable event-sourced ORM with off-the-shelf database storage
- ✅ Rust structs for data modeling
- ✅ Signals pattern for notifications
- ✅ WASM Bindings for client-side use
- ✅ WebSocket server and client
- ✅ React Bindings
- ✅ Basic included data-types: CRDT text (yrs crate) and primitive types
- ✅ Embedded KV backing store (Sled DB)
- ✅ Basic, single field queries (auto-indexed)
- ✅ Postgres backend
- ✅ Multi-field queries
- ✅ Robust recursive query AST for declarative queries
Major Milestone 2 - Stuff we need, but can live without for a bit
Enhanced functionality:
- TiKV Backend
- Graph Functionality
- User-definable data types
- Advanced indexing strategies
- Query optimization
- Performance profiling tools
Major Milestone 3 - Maybe someday...
Future aspirations:
- P2P functionality: Direct peer-to-peer connections without central servers
- Portable cryptographic identities: User identities that work across nodes
- E2EE (End-to-End Encryption): Privacy-preserving data synchronization
- Hypergraph functionality: More complex relationship modeling
- CRDT compaction: Efficient storage of long operation histories
- Byzantine fault tolerance: Security against malicious nodes
Design Philosophy
Ankurah prioritizes:
- Developer Experience: Easy to learn, hard to misuse
- Type Safety: Compile-time guarantees where possible
- Flexibility: Support various storage backends and use cases
- Performance: Efficient synchronization and querying
- Scalability: From embedded devices to large distributed systems
Inspirations
Ankurah draws inspiration from:
- Event Sourcing: CQRS, Event Store
- Reactive Programming: SolidJS signals, MobX
- ActiveRecord: Ruby on Rails, Ecto (Elixir)
- Distributed Systems: CRDTs, operational transformation
- Modern Databases: Postgres, TiKV, FaunaDB
Contributing
We welcome contributions! Join the discussion:
Help shape the future of Ankurah by:
- Reporting bugs and suggesting features
- Improving documentation
- Contributing code
- Building example applications
- Sharing your use cases
Examples
This page contains practical code examples demonstrating key Ankurah features.
Inter-Node Subscription
This example shows how to set up a server and client node, connect them, and subscribe to changes:
#![allow(unused)] fn main() { use ankurah::prelude::*; use ankurah_storage_sled::SledStorageEngine; use ankurah_connector_local_process::LocalProcessConnection; // Create server node with durable storage let server = Node::new_durable( Arc::new(SledStorageEngine::new_test()?), PermissiveAgent::new() ); // Initialize a new "system" (only done once on first startup) server.system.create()?; // Create a context for the server let server = server.context(context_data)?; // Create client node let client = Node::new( Arc::new(SledStorageEngine::new_test()?), PermissiveAgent::new() ); // Connect nodes using local process connection let _conn = LocalProcessConnection::new(&server, &client).await?; // Wait for the client to join the server "system" client.system.wait_system_ready().await; let client = client.context(context_data)?; // Subscribe to changes on the client let subscription = client.subscribe::<_,_,AlbumView>( "name = 'Origin of Symmetry'", |changes| { println!("Received changes: {}", changes); } ).await?; // Create a new album on the server let trx = server.begin(); let album = trx.create(&Album { name: "Origin of Symmetry".into(), year: "2001".into(), }).await?; trx.commit().await?; // The subscription callback will fire automatically! }
Defining a Model
Models define the schema for your entities:
#![allow(unused)] fn main() { use ankurah::prelude::*; // Define your model #[derive(Model, Clone, Debug)] struct BlogPost { title: String, content: String, author: String, published: bool, tags: Vec<String>, } // This automatically generates: // - BlogPostView (read-only) // - BlogPostMutable (for updates) }
Creating Entities
#![allow(unused)] fn main() { // Start a transaction let trx = context.begin(); // Create a new entity let post = trx.create(&BlogPost { title: "Getting Started with Ankurah".into(), content: "Ankurah makes distributed state management easy...".into(), author: "Alice".into(), published: true, tags: vec!["tutorial".into(), "rust".into()], }).await?; // Commit the transaction trx.commit().await?; println!("Created post with ID: {}", post.id()); }
Reading Entities
#![allow(unused)] fn main() { // Get a view of the entity (read-only) let view: BlogPostView = post.view()?; println!("Title: {}", view.title); println!("Author: {}", view.author); println!("Published: {}", view.published); }
Updating Entities
#![allow(unused)] fn main() { // Start a transaction let trx = context.begin(); // Get a mutable handle let mut mutable: BlogPostMutable = post.mutable(&trx)?; // Update fields mutable.title.set("Updated: Getting Started with Ankurah"); mutable.published.set(true); // Commit changes trx.commit().await?; }
Querying with Subscriptions
Subscriptions let you receive real-time updates for entities matching a query:
#![allow(unused)] fn main() { // Subscribe to all published posts let sub = context.query::<BlogPostView>( "published = true", |changes| { for change in changes.created { println!("New published post: {}", change.view.title); } for change in changes.updated { println!("Updated post: {}", change.view.title); } } ).await?; // Subscribe with complex queries let sub = context.query::<BlogPostView>( "published = true AND author = 'Alice' AND tags CONTAINS 'rust'", |changes| { println!("Alice published a new Rust post!"); } ).await?; }
Using Signals in React
Ankurah provides React hooks for reactive UI updates:
import { useQuery, useEntity } from "ankurah-react";
function BlogPostList() {
// Subscribe to all published posts
const posts = useQuery<BlogPost>("BlogPost", "published = true");
return (
<div>
{posts.map((post) => (
<BlogPostCard key={post.id} postId={post.id} />
))}
</div>
);
}
function BlogPostCard({ postId }) {
// Subscribe to a specific entity
const post = useEntity<BlogPost>(postId);
if (!post) return <div>Loading...</div>;
return (
<div>
<h2>{post.title}</h2>
<p>By {post.author}</p>
<p>{post.content}</p>
</div>
);
}
WebSocket Client Setup (WASM)
Connect a browser client to a server:
#![allow(unused)] fn main() { use ankurah_wasm::*; use ankurah_connector_websocket_client_wasm::*; // Create a client node with IndexedDB storage let storage = IndexedDBStorageEngine::new("my-app").await?; let client = Node::new(Arc::new(storage), PermissiveAgent::new()); // Connect to server via WebSocket let ws = WebSocketClientWasm::connect( "ws://localhost:8080", &client ).await?; // Wait for system to be ready client.system.wait_system_ready().await; // Now you can use the client normally let context = client.context(user_data)?; }
Transaction Error Handling
#![allow(unused)] fn main() { use ankurah::error::Result; async fn create_post_with_validation( context: &Context, title: &str, content: &str, ) -> Result<Entity> { // Validate input if title.is_empty() { return Err(AnkurahError::validation("Title cannot be empty")); } let trx = context.begin(); // Create the post let post = trx.create(&BlogPost { title: title.into(), content: content.into(), author: "System".into(), published: false, tags: vec![], }).await?; // Commit (or automatically rollback on error) trx.commit().await?; Ok(post) } }
Working with Collections
#![allow(unused)] fn main() { // Get a collection reference let posts = context.collection::<BlogPost>("BlogPost"); // Count entities let count = posts.count().await?; println!("Total posts: {}", count); // Iterate all entities (careful with large collections!) let all_posts = posts.all().await?; for post in all_posts { let view: BlogPostView = post.view()?; println!("- {}", view.title); } }
Custom Storage Backend
#![allow(unused)] fn main() { use ankurah::storage::*; // Create nodes with different backends // Sled (embedded KV) let node = Node::new( Arc::new(SledStorageEngine::new("./data")?), agent ); // Postgres let node = Node::new( Arc::new(PostgresStorageEngine::connect("postgresql://...").await?), agent ); // IndexedDB (WASM only) let node = Node::new( Arc::new(IndexedDBStorageEngine::new("my-app").await?), agent ); }
Next Steps
- Check out the Getting Started guide for step-by-step setup
- Review the Glossary to understand key terms
- Study the Architecture to see how it all fits together
- Join the Discord to discuss your use case!