Architecture

SwirlDB is a composition framework where you assemble your database from independent, swappable adapters.

Core Philosophy: Everything is an Adapter

No Monolith, Just Composition

Traditional databases give you a single implementation with feature flags and configuration. SwirlDB gives you traits and adapters:

  • Storage: Choose your backend (memory, localStorage, IndexedDB, redb, S3, custom)
  • Sync: Choose your protocol (WebSocket, HTTP, WebRTC, custom)
  • Auth: Choose your policy engine (JWT, OAuth, ABAC, custom)
  • Encryption: Choose your crypto (AES-GCM, field-level, custom)

Key insight: There are no "default" implementations or "primary" platforms. Every component is a peer. Every environment is equal.

The 3-Crate System

SwirlDB uses complete separation of concerns:

๐Ÿฆ€ swirldb-core

Platform-agnostic Rust library

Contains:

  • CRDT engine (Automerge-based)
  • Trait definitions (DocumentStorage, ChangeLog)
  • Policy engine (path-based authorization)
  • Auth provider interface
  • In-memory reference implementations

Does NOT contain:

  • โŒ WASM bindings
  • โŒ Browser APIs
  • โŒ Server networking
  • โŒ Platform-specific code

Pure Rust. Platform-agnostic. Thread-safe.

๐ŸŒ swirldb-browser

WASM bindings for browsers

Contains:

  • JavaScript bindings via wasm-bindgen
  • localStorage adapter (5-10MB)
  • IndexedDB adapter (50MB-1GB)
  • JavaScript interop
  • Web API implementations

Build output:

  • ~479KB WASM binary (gzipped)
  • ~10KB JavaScript glue (gzipped)
  • TypeScript definitions

Target: wasm32-unknown-unknown. Single-threaded (?Send).

๐Ÿ–ฅ๏ธ swirldb-server

Pure Rust binary for servers

Contains:

  • HTTP/WebSocket server (axum + tokio)
  • redb adapter (persistent, ACID)
  • Memory adapter (volatile, fast)
  • Binary sync protocol
  • Namespace management

Does NOT contain:

  • โŒ Node.js dependencies
  • โŒ npm packages
  • โŒ JavaScript runtime

Pure native binary. Multi-threaded (Send + Sync).

Browser and Server are Equivalent Nodes

This is a fundamental design principle: browser and server are peers, not client and server.

Same Traits, Different Implementations

Both environments implement identical storage traits:

// Defined in swirldb-core/src/storage/mod.rs
trait DocumentStorage {
    async fn save(&self, key: &str, data: &[u8]) -> Result<()>;
    async fn load(&self, key: &str) -> Result<Option<Vec<u8>>>;
    async fn delete(&self, key: &str) -> Result<()>;
    async fn list_keys(&self) -> Result<Vec<String>>;
}

trait ChangeLog {
    async fn init(&mut self) -> Result<()>;
    async fn append_change(&self, namespace_id: &str, change: Change) -> Result<()>;
    async fn append_changes(&self, namespace_id: &str, changes: Vec<Change>) -> Result<()>;
    async fn get_changes(&self, namespace_id: &str) -> Result<Vec<Change>>;
    async fn get_changes_since(&self, namespace_id: &str, since: i64) -> Result<Vec<Change>>;
    async fn change_count(&self, namespace_id: &str) -> Result<usize>;
    async fn delete_namespace(&self, namespace_id: &str) -> Result<()>;
}

Browser Implementations

// swirldb-browser/src/storage.rs

// localStorage adapter
pub struct LocalStorageAdapter { /* ... */ }

#[async_trait(?Send)]  // Single-threaded WASM
impl DocumentStorage for LocalStorageAdapter {
    async fn save(&self, key: &str, data: &[u8]) -> Result<()> {
        // Uses web_sys::Storage API
    }
    // ...
}

// IndexedDB adapter
pub struct IndexedDBAdapter { /* ... */ }

#[async_trait(?Send)]
impl DocumentStorage for IndexedDBAdapter {
    async fn save(&self, key: &str, data: &[u8]) -> Result<()> {
        // Uses web_sys::IdbDatabase API
    }
    // ...
}

Server Implementations

// swirldb-server/src/storage/redb_adapter.rs

// redb adapter
pub struct RedbAdapter { /* ... */ }

#[async_trait]  // Multi-threaded, Send + Sync
impl DocumentStorage for RedbAdapter {
    async fn save(&self, key: &str, data: &[u8]) -> Result<()> {
        // Uses redb embedded database
    }
    // ...
}

#[async_trait]
impl ChangeLog for RedbAdapter {
    async fn append_change(&self, namespace_id: &str, change: Change) -> Result<()> {
        // Persistent change log
    }
    // ...
}

Key insight: The only difference is the #[async_trait(?Send)] marker. Browser is single-threaded, server is multi-threaded. Otherwise, identical APIs.

Shared Implementations

The pure Rust core is shared by both browser and server. There is no duplication, no divergence, no platform-specific CRDT logic.

What's Shared

  • CRDT Engine: Automerge-based document merging (swirldb-core/src/core.rs)
  • Storage Traits: DocumentStorage + ChangeLog definitions (swirldb-core/src/storage/mod.rs)
  • Policy Engine: Path-based authorization logic (swirldb-core/src/policy.rs)
  • Auth Providers: JWT, OAuth, ABAC interfaces (swirldb-core/src/auth.rs)
  • In-Memory Storage: Reference implementations used for testing

What's Different

  • Browser: Web APIs (localStorage, IndexedDB), WASM FFI, JavaScript interop
  • Server: Native I/O (redb, filesystem), HTTP/WebSocket, multi-threading

This separation means:

  • โœ… Bug fixes in core benefit both platforms simultaneously
  • โœ… CRDT behavior is identical across all environments
  • โœ… New features land everywhere at once
  • โœ… Testing core logic once validates all platforms

Runtime Composition

Adapters are swapped at runtime, not compile time. No recompilation needed to change behavior.

Example: Storage Adapter Switching

// Browser: Start with memory, switch to localStorage later
let db = await SwirlDB.create();  // In-memory

// Later, migrate to persistent storage
const state = db.saveState();
db = await SwirlDB.withLocalStorage('my-app');
db.loadState(state);

// Or switch to IndexedDB for larger storage
db = await SwirlDB.withIndexedDB('my-app');
db.loadState(state);

Example: Server Adapter Composition

// Server: Start with memory for development
let storage = Arc::new(MemoryAdapter::new());
let db = SwirlDB::with_storage(storage, "db").await;

// Deploy with redb for production
let storage = Arc::new(RedbAdapter::new("./data")?);
let db = SwirlDB::with_storage(storage, "db").await;

// Same code. Same APIs. Just different adapter.

Access Control with Policy Engine

Control who can read and write specific paths using the policy engine. Policies are defined as JSON documents.

Policy-Based Access Control

// Define access rules in JSON
const policy = {
  rules: [
    {
      path: "/user/:userId/**",
      read: ["owner", "admin"],
      write: ["owner"]
    },
    {
      path: "/public/**",
      read: ["*"],
      write: ["authenticated"]
    }
  ]
};

// Apply policy to database instance
db.withPolicy(policy);

Policies enable fine-grained control: different paths, different permissions.

Building Custom Adapters

The trait system is fully extensible. Roll your own adapters for any backend.

Example: Custom Redis Adapter

use redis::{Client, AsyncCommands};

pub struct RedisAdapter {
    client: Client,
}

impl RedisAdapter {
    pub fn new(url: &str) -> Result<Self> {
        Ok(RedisAdapter {
            client: Client::open(url)?,
        })
    }
}

impl DocumentStorageMarker for RedisAdapter {}

#[async_trait]
impl DocumentStorage for RedisAdapter {
    async fn save(&self, key: &str, data: &[u8]) -> Result<()> {
        let mut conn = self.client.get_async_connection().await?;
        conn.set(key, data).await?;
        Ok(())
    }

    async fn load(&self, key: &str) -> Result<Option<Vec<u8>>> {
        let mut conn = self.client.get_async_connection().await?;
        let result: Option<Vec<u8>> = conn.get(key).await?;
        Ok(result)
    }

    async fn delete(&self, key: &str) -> Result<()> {
        let mut conn = self.client.get_async_connection().await?;
        conn.del(key).await?;
        Ok(())
    }

    async fn list_keys(&self) -> Result<Vec<String>> {
        let mut conn = self.client.get_async_connection().await?;
        let keys: Vec<String> = conn.keys("*").await?;
        Ok(keys)
    }
}

// Now use it just like any other adapter!
let storage = Arc::new(RedisAdapter::new("redis://localhost")?);
let db = SwirlDB::with_storage(storage, "db").await;

That's it. Implement the trait, pass it to SwirlDB::with_storage(), and you're done.

Why This Architecture?

๐Ÿ”Œ True Modularity

No monolithic codebase. Each adapter is independent, testable, replaceable.

โš–๏ธ No Primary Platform

Browser and server are peers. No "privileged" environment. Equal functionality.

๐Ÿ”„ Shared Logic

CRDT engine is written once, used everywhere. Bug fixes land in all platforms.

๐Ÿš€ Runtime Flexibility

Swap adapters without recompilation. Deploy same code, different configuration.

๐Ÿงช Testability

Test core traits with in-memory adapters. Integration tests use real adapters.

๐ŸŒ Ecosystem

Community can build adapters for any backend. Unlimited extensibility.