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 beta status. It works, but be careful with 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!
Defining Models
Models define the structure of your entities. Define them once in Rust, and they work everywhere—native servers, browser clients, and mobile apps.
Basic Model Definition
Use the #[derive(Model)] macro to define a model:
#[derive(Model, Debug, Serialize, Deserialize)]
pub struct Album {
#[active_type(YrsString)]
pub name: String,
pub artist: String,
pub year: i32,
}
This single definition generates:
| Generated Type | Purpose |
|---|---|
Album | The model struct for creating new entities |
AlbumView | Read-only view of an entity’s current state |
AlbumMut | Mutable handle for updating entities in a transaction |
Field Types
Basic Types
#[derive(Model, Debug, Serialize, Deserialize)]
pub struct Task {
pub title: String,
pub completed: bool,
pub priority: i32,
}
Supported types include:
Stringbool- Integers:
i8,i16,i32,i64,u8,u16,u32,u64 f32,f64
CRDT Types
Use #[active_type(...)] to specify a CRDT backend for a field. The first supported CRDT type is YrsString for collaborative text:
#[derive(Model, Debug, Serialize, Deserialize)]
pub struct Document {
#[active_type(YrsString)]
pub content: String,
pub title: String,
}
Entity References
Use Ref<T> to create typed references between entities:
#[derive(Model, Debug, Serialize, Deserialize, Clone)]
pub struct Artist {
pub name: String,
}
#[derive(Model, Debug, Serialize, Deserialize, Clone)]
pub struct Song {
pub title: String,
pub artist: Ref<Artist>,
}
References enable graph-style navigation between related entities.
JSON Fields
Use Json for schemaless, dynamic data:
#[derive(Model, Debug, Serialize, Deserialize, Clone)]
pub struct Track {
pub name: String,
pub metadata: Json,
}
JSON fields support nested path queries like metadata.genre = 'rock'.
Creating Entities
Use a transaction to create new entities:
let trx = ctx.begin();
let album = trx.create(&Album {
name: "Parade".into(),
artist: "Prince".into(),
year: 1986,
}).await?;
let album_id = album.id();
trx.commit().await?;
Reading Entities
Access data through the View type:
let view: AlbumView = ctx.get(album_id).await?;
println!("Album: {} by {} ({})", view.name()?, view.artist()?, view.year()?);
Generated TypeScript
When you build your WASM bindings, TypeScript types are generated automatically:
// Generated from your Rust model
interface AlbumView {
id: EntityId;
name: string;
artist: string;
year: number;
}
// Static methods on the model class
class Album {
static query(ctx: Context, query: string): AlbumLiveQuery;
static create(trx: Transaction, data: AlbumData): Promise<AlbumView>;
}
Next Steps
- Querying Data - How to query and filter entities
- Query Syntax - Full AnkQL syntax reference
Querying Data
Ankurah provides a SQL-like query language called AnkQL for filtering and retrieving entities. Queries work consistently across all storage backends—whether you’re querying Postgres on your server or IndexedDB in a browser.
Two Ways to Query
There are two fundamental patterns for getting data:
| Method | Returns | Use When |
|---|---|---|
fetch() | One-time snapshot | You need data once (e.g., checking if something exists) |
query() | Live subscription | You want automatic updates when data changes |
fetch() - One-Time Snapshot
Use fetch() when you need data once and don’t need ongoing updates:
// Fetch with a string query - one-time snapshot
let albums: Vec<AlbumView> = ctx.fetch("year > 1985").await?;
The results are a Vec<AlbumView> containing all matching entities at that moment. If the data changes later, you won’t be notified.
query() - Live Subscription
Use query() when your UI should update automatically as data changes:
// Using selection! macro with ctx.query()
let q: LiveQuery<AlbumView> = ctx.query(selection!("year > 1985"))?;
A LiveQuery is reactive—when entities matching your query are created, updated, or deleted (anywhere in the system), the query’s results update automatically.
Query Methods
Using Macros (Recommended)
The recommended way to query is using the fetch! and selection! macros, which provide compile-time safety and variable interpolation:
Variable Interpolation with Macros
Use the fetch! and selection! macros for dynamic queries. They support multiple syntaxes:
Unquoted Form (Terse)
The unquoted form is the most concise. Variables expand to equality comparisons by default:
// Unquoted form: {variable} expands to variable = {variable}
let artist = "Prince";
let albums: Vec<AlbumView> = fetch!(ctx, {artist}).await?;
Add comparison operators as prefixes: {>year}, {<year}, {>=year}, {<=year}, {!=year}:
// Unquoted form with comparison operator: {>year} expands to year > {year}
let year = 1985;
let albums: Vec<AlbumView> = fetch!(ctx, {>year}).await?;
Combine multiple conditions with AND/OR:
// Combine multiple conditions with AND/OR
let artist = "Prince";
let year = 1985;
let albums: Vec<AlbumView> = fetch!(ctx, {artist} AND {>year}).await?;
Mix unquoted variables with explicit comparisons:
// Mix unquoted variables with explicit comparisons
let artist = "Prince";
let year = 1985;
let albums: Vec<AlbumView> = fetch!(ctx, {artist} AND year > {year}).await?;
Quoted Form (Flexible)
Use quoted form for string literals and positional arguments:
// Quoted form for pure string literals
let albums: Vec<AlbumView> = fetch!(ctx, "artist = 'Prince' AND year > 1985").await?;
Positional arguments with {}:
// Quoted form with positional arguments
let min_year = 1980;
let max_year = 1990;
let albums: Vec<AlbumView> = fetch!(ctx, "year >= {} AND year <= {}", min_year, max_year).await?;
Multiple positional arguments:
// Quoted form with named variable interpolation
let artist = "Prince";
let year = 1985;
let albums: Vec<AlbumView> = fetch!(ctx, "artist = '{}' AND year > {}", artist, year).await?;
With query() and selection!
The same syntaxes work with ctx.query(selection!(...)):
// Unquoted form with selection! macro
let artist = "Prince";
let live: LiveQuery<AlbumView> = ctx.query(selection!({artist}))?;
// Unquoted form with comparison operator
let year = 1985;
let live: LiveQuery<AlbumView> = ctx.query(selection!({>year}))?;
// Combine conditions with AND/OR
let artist = "Prince";
let year = 1985;
let live: LiveQuery<AlbumView> = ctx.query(selection!({artist} AND {>year}))?;
// Quoted form for string literals
let live: LiveQuery<AlbumView> = ctx.query(selection!("artist = 'Prince' AND year > 1985"))?;
Next Steps
- Query Syntax - Learn the full AnkQL query language
- React Usage - Using queries in React components
AnkQL Syntax
AnkQL is Ankurah’s query language for filtering entities. It uses familiar SQL-like syntax that works consistently across all storage backends.
Basic Comparisons
field = value # Equality
field != value # Not equal
field > value # Greater than
field >= value # Greater than or equal
field < value # Less than
field <= value # Less than or equal
Examples
let albums: Vec<AlbumView> = ctx.fetch("name = 'Dark Side of the Moon'").await?;
let albums: Vec<AlbumView> = ctx.fetch("year > 1985").await?;
let albums: Vec<AlbumView> = ctx.fetch("artist != 'Unknown'").await?;
Logical Operators
Combine conditions with AND and OR:
condition1 AND condition2
condition1 OR condition2
Use parentheses for complex logic:
(condition1 OR condition2) AND condition3
Examples
let albums: Vec<AlbumView> = ctx.fetch("year > 1980 AND year < 1990").await?;
let albums: Vec<AlbumView> = ctx.fetch("artist = 'Prince' OR artist = 'Madonna'").await?;
let albums: Vec<AlbumView> = ctx.fetch("(artist = 'Prince' OR artist = 'Madonna') AND year > 1985").await?;
The IN Operator
Check if a value is in a list:
field IN (value1, value2, value3)
Example
let albums: Vec<AlbumView> = ctx.fetch("year IN (1984, 1985, 1986)").await?;
Ordering Results
Use ORDER BY to sort results:
... ORDER BY field ASC
... ORDER BY field DESC
Examples
let albums: Vec<AlbumView> = ctx.fetch("year > 1980 ORDER BY year DESC").await?;
let albums: Vec<AlbumView> = ctx.fetch("true ORDER BY name ASC").await?;
Selecting All Entities
Use true to match all entities:
let albums: Vec<AlbumView> = ctx.fetch("true ORDER BY name ASC").await?;
String Values
String literals use single quotes:
let albums: Vec<AlbumView> = ctx.fetch("name = 'Purple Rain'").await?;
To include a single quote in a string, escape it with another single quote:
let albums: Vec<AlbumView> = ctx.fetch("name = 'Rock ''n'' Roll'").await?;
Variable Interpolation
Use the fetch! and selection! macros for dynamic queries. They support multiple syntaxes:
Unquoted Form
The unquoted form is the most concise. Variables expand to equality by default:
#![allow(unused)]
fn main() {
let artist = "Prince";
fetch!(ctx, {artist}).await?; // Equivalent to: artist = 'Prince'
}
Add comparison operators as prefixes:
// Unquoted form: {>year} expands to year > {year}
let year = 1985;
let albums: Vec<AlbumView> = fetch!(ctx, {>year}).await?;
All comparison operators work: {>var}, {<var}, {>=var}, {<=var}, {!=var}:
// All comparison operators work: >, <, >=, <=, !=
let year = 1985;
let _newer: Vec<AlbumView> = fetch!(ctx, {>year}).await?;
let _older: Vec<AlbumView> = fetch!(ctx, {<year}).await?;
let _gte: Vec<AlbumView> = fetch!(ctx, {>=year}).await?;
let _lte: Vec<AlbumView> = fetch!(ctx, {<=year}).await?;
let _not_eq: Vec<AlbumView> = fetch!(ctx, {!=year}).await?;
Combine conditions with AND/OR:
// Combine multiple conditions with AND/OR
let artist = "Prince";
let year = 1985;
let albums: Vec<AlbumView> = fetch!(ctx, {artist} AND {>year}).await?;
Mix unquoted variables with explicit comparisons:
// Mix unquoted variables with explicit comparisons
let artist = "Prince";
let year = 1985;
let albums: Vec<AlbumView> = fetch!(ctx, {artist} AND year > {year}).await?;
Quoted Form
Use quoted form for string literals and positional arguments:
// Quoted form with positional argument for string values
let artist = "Prince";
let albums: Vec<AlbumView> = fetch!(ctx, "artist = '{}'", artist).await?;
Multiple variables:
// Multiple variables with quoted form
let min_year = 1980;
let max_year = 1990;
let albums: Vec<AlbumView> = fetch!(ctx, "year >= {} AND year <= {}", min_year, max_year).await?;
Pure string literals (no variables):
// Quoted form for pure string literals
let albums: Vec<AlbumView> = fetch!(ctx, "artist = 'Prince' AND year > 1985").await?;
Common Patterns
Check if entity exists
// Check if any entities match the query
let album_name = "Purple Rain";
let matching_albums: Vec<AlbumView> = fetch!(ctx, "name = '{}'", album_name).await?;
let exists = matching_albums.len() > 0;
Get first match
let album = ctx.fetch::<AlbumView>("name = 'Purple Rain'").await?.into_iter().next();
Count matches
let count = ctx.fetch::<AlbumView>("year > 1985").await?.len();
Next Steps
- Querying Data - Overview of fetch vs query
- React Usage - Using queries in React components
Queries in React
Ankurah provides TypeScript bindings that are automatically generated from your Rust model. These bindings include React-friendly APIs for live queries.
Setup
The WASM bindings package exports everything you need:
import {
Album, // Generated model class
AlbumView, // Read-only view type
AlbumLiveQuery, // LiveQuery type for this model
ctx, // Get the current context
useObserve, // React hook for signal observation
} from "your-wasm-bindings";
Creating Queries
Use the static .query() method on any model class:
const q: AlbumLiveQuery = Album.query(ctx(), "year > 1985");
The query returns immediately with a LiveQuery object. Results stream in as they become available.
Signal Observation
Ankurah uses signals for reactivity. To make a React component reactive, wrap it with signalObserver:
/* creates and Binds a ReactObserver to the component */
const AlbumList = signalObserver(({ albums }: Props) => {
return (
<ul>
/* React Observer automatically tracks albums */
{albums.items.map((album) => (
<li>{album.name}</li>
))}
</ul>
);
});
The signalObserver wrapper:
- Creates a reactive observer for the component render
- Automatically tracks which signals are accessed during render
- Re-renders the component when those signals change
How It Works
When you access albums.items inside a component wrapped with signalObserver, the observer tracks this access. When the live query’s results change—whether from local changes or remote sync—the component automatically re-renders.
Creating Entities
Use a transaction to create new entities:
const createRoom = async (name: string) => {
const transaction = ctx().begin();
const room = await Room.create(transaction, {
name: name.trim(),
});
await transaction.commit();
return room;
};
Querying All Entities
Use an empty string to match all entities:
// Query for all users
const users = useMemo(() => User.query(ctx(), ""), []);
Memoization
Wrap your queries in useMemo to avoid recreating them on every render:
const albums = useMemo(() => {
return Album.query(ctx(), "year > 1985");
}, []); // Empty deps = create once
For dynamic queries, include the parameters in the dependency array:
const albums = useMemo(() => {
return Album.query(ctx(), `artist = '${artistName}'`);
}, [artistName]); // Recreate when artistName changes
Reactive State with JsValueMut
For local reactive state that integrates with the signal system:
import { JsValueMut, JsValueRead } from "your-wasm-bindings";
// Mutable signal - can be read and written
const selectedRoom = useMemo(() => new JsValueMut<RoomView | null>(null), []);
// Read the current value (tracked by observer)
const room = selectedRoom.get();
// Update the value (triggers re-render)
selectedRoom.set(newRoom);
Complete Example
import { useMemo, useEffect } from "react";
import { Room, RoomLiveQuery, ctx, JsValueMut } from "your-wasm-bindings";
import { signalObserver } from "./utils";
interface RoomListProps {
selectedRoom: JsValueMut<RoomView | null>;
}
export const RoomList = signalObserver(({ selectedRoom }) => {
// Create a live query for all rooms
const rooms = useMemo(() => Room.query(ctx(), ""), []);
// Access items - tracked by signalObserver
const items = rooms.items;
const currentRoom = selectedRoom.get();
return (
<div className="room-list">
{items.map((room) => (
<div
key={room.id.to_base64()}
className={currentRoom?.id.to_base64() === room.id.to_base64() ? 'selected' : ''}
onClick={() => selectedRoom.set(room)}
>
{room.name}
</div>
))}
</div>
);
});
Next Steps
- Querying Data - Overview of fetch vs query
- Query Syntax - Full AnkQL syntax reference
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() {
let livequery = context.query::<AlbumView>(
"name LIKE 'Origin%' AND year > '2000'"
).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.
Defining a Model
#[derive(Model, Debug, Serialize, Deserialize)]
pub struct Album {
#[active_type(YrsString)]
pub name: String,
pub artist: String,
pub year: i32,
}
This automatically generates:
AlbumView(read-only)AlbumMutable(for updates)
See Defining Models for full documentation.
Server Setup
let storage = SledStorageEngine::with_path(storage_dir)?;
let node = Node::new_durable(Arc::new(storage), PermissiveAgent::new());
node.system.create().await?;
let mut server = WebsocketServer::new(node);
println!("Running server...");
server.run("0.0.0.0:9797").await?;
Rust Client
let storage = SledStorageEngine::new_test()?;
let node = Node::new(Arc::new(storage), PermissiveAgent::new());
let _client = WebsocketClient::new(node.clone(), "ws://localhost:9797").await?;
node.system.wait_system_ready().await;
// Create album
let ctx = node.context(ankurah::policy::DEFAULT_CONTEXT)?;
let trx = ctx.begin();
trx.create(&Album { name: "Parade".into(), artist: "Prince".into(), year: 1986 }).await?;
trx.commit().await?;
React Component
/* creates and Binds a ReactObserver to the component */
const AlbumList = signalObserver(({ albums }: Props) => {
return (
<ul>
/* React Observer automatically tracks albums */
{albums.items.map((album) => (
<li>{album.name}</li>
))}
</ul>
);
});
See React Usage for full documentation.
Live Query
// Using selection! macro with ctx.query()
let q: LiveQuery<AlbumView> = ctx.query(selection!("year > 1985"))?;
See Querying Data for full documentation.
Entity References
Create relationships between entities with Ref<T>:
// Create an artist
let trx = ctx.begin();
let artist = trx.create(&Artist { name: "Radiohead".into() }).await?;
let artist_id = artist.id();
trx.commit().await?;
// Create a song that references the artist
let trx = ctx.begin();
trx.create(&Song {
title: "Paranoid Android".into(),
artist: Ref::new(artist_id),
}).await?;
trx.commit().await?;
Traverse references to fetch related entities:
// Fetch the song and traverse to get the artist
let songs: Vec<SongView> = ctx.fetch("title = 'Paranoid Android'").await?;
let song = songs.first().unwrap();
// Get the referenced artist entity
let artist: ArtistView = song.artist()?.get(&ctx).await?;
println!("Artist: {}", artist.name()?);
JSON Queries
Create entities with dynamic JSON fields:
let trx = ctx.begin();
trx.create(&Track {
name: "Test Track".into(),
metadata: Json::new(serde_json::json!({
"genre": "rock",
"bpm": 120,
"tags": ["guitar", "drums"]
})),
}).await?;
trx.commit().await?;
Query by nested JSON paths:
// Query by nested JSON path
let tracks: Vec<TrackView> = ctx.fetch("metadata.genre = 'rock'").await?;
Numeric comparisons work too:
// Numeric comparison on JSON field
let fast_tracks: Vec<TrackView> = ctx.fetch("metadata.bpm > 100").await?;
Storage Backends
Sled (Embedded)
let storage = SledStorageEngine::new()?;
Postgres
let storage = Postgres::open(uri).await?;
IndexedDB (WASM)
#![allow(unused)]
fn main() {
let storage = IndexedDBStorageEngine::new("my-app").await?;
}
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!