Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.ditto.live/llms.txt

Use this file to discover all available pages before exploring further.

Overview

This guide covers the essential changes needed to migrate your Ditto Rust app from v4 to v5. The main architectural shift is moving from identity/builder-based initialization to a four-phase configuration model with async APIs and Result-based error handling. Also required: v5 uses DQL (Ditto Query Language) for all data operations. See the DQL Migration Guide for query migration steps.

AI Agent Prompt

Use this prompt when working with an AI coding assistant to migrate your Ditto Rust app from v4 to v5.
I need help migrating a Ditto Rust application from v4 to v5. Focus on these critical changes:

INITIALIZATION:
Replace Ditto::builder()...build() with Ditto::open(config).
- Create DittoConfig::new(database_id, connect) β€” NOT a builder pattern
- OnlinePlayground β†’ DittoConfigConnect::Server { url: "https://...".parse().unwrap() }
- OfflinePlayground β†’ DittoConfigConnect::SmallPeersOnly { private_key: None }
- SharedKey β†’ DittoConfigConnect::SmallPeersOnly { private_key: Some(key_bytes) }
- Manual β†’ No v5 equivalent
- Ditto::open(config) is async (returns Result<Ditto, DittoError>)
- Ditto::open_sync(config) is the blocking alternative
- URL fields use url::Url type, not String β€” parse with .parse().unwrap()
- Remove update_transport_config β€” v5 automatically configures the websocket URL from the Server connect URL
- Remove disable_sync_with_v3 and DQL strict mode workarounds
- IMPORTANT: If v4 app used default (strict=true), set DQL_STRICT_MODE=true BEFORE starting sync

AUTHENTICATION:
- ditto.auth() returns Option<DittoAuthenticator> β€” None in SmallPeersOnly mode
- set_expiration_handler requires a handler callable multiple times (AsyncFn, NOT AsyncFnOnce)
- IMPORTANT: async move closures that capture variables will NOT compile β€” they implement AsyncFnOnce only
- IMPORTANT: async closures without move capture by reference and won't satisfy the 'static bound
- RECOMMENDED: Implement DittoAuthExpirationHandler on a struct that holds captured state (token, config)
  - on_expiration takes &self, so the struct persists; .clone() state into each async block
  - Call ditto.auth() synchronously before the async move block to avoid lifetime issues
- auth.login(token, provider) is SYNCHRONOUS β€” returns Result<AuthenticationClientFeedback, DittoError>
- Use get_development_provider() for playground tokens (NOT "__playgroundProvider")
- Handler called when duration_remaining == Duration::ZERO (initial) or > 0 (refresh)

SYNC:
- ditto.start_sync() β†’ ditto.sync().start()
- ditto.stop_sync() β†’ ditto.sync().stop()
- ditto.is_sync_active() β†’ ditto.sync().is_active()

STORE ACCESS:
- ditto.store() is a method (with parentheses), not a field
- store.execute_v2() β†’ store.execute() β€” uses IntoQuery trait
- store.register_observer_v2() β†’ store.register_observer()
- store.register_observer_with_signal_next_v2() β†’ store.register_observer_with_signal_next()
- For parameterized queries: store().execute(("SELECT ... WHERE x = :x", json!({"x": value}))).await?

PRESENCE:
- Peer.peer_key_string β†’ Peer.peer_key: String (unified, Vec<u8> variant removed)
- Peer.is_connected_to_ditto_cloud β†’ Peer.is_connected_to_ditto_server
- Connection.peer_key_string1/2 β†’ Connection.peer1/2
- Connection.approximate_distance_in_meters β†’ removed

TYPES:
- AppId β†’ DatabaseId
- DiskUsageChild β†’ DiskUsageItem
- Ditto::with_sdk_version(|v| {...}) β†’ Ditto::version() (static, returns String)
- WriteStrategy enum β†’ removed (DQL handles write semantics)
- DittoBuilder β†’ removed (use DittoConfig::new + Ditto::open)

BEFORE (V4):
```rust
let ditto = Ditto::builder()
    .with_root(Arc::new(PersistentRoot::from_current_exe()?))
    .with_identity(|ditto_root| {
        identity::OnlinePlayground::new(
            ditto_root, "your-app-id", "your-token", false,
        )
    })?
    .build()?;

ditto.update_transport_config(|config| {
    config.connect.websocket_urls.insert("wss://...".to_string());
});
ditto.store().execute_v2("ALTER SYSTEM SET DQL_STRICT_MODE = false", None).await?;
ditto.disable_sync_with_v3()?;
ditto.start_sync()?;
```

AFTER (V5):
```rust
use std::future::Future;

// Struct-based handler β€” async move closures with captured state won't compile
struct AuthHandler { token: String }
impl DittoAuthExpirationHandler for AuthHandler {
    fn on_expiration(&self, ditto: &Ditto, _: Duration) -> impl Send + Future<Output = ()> {
        let token = self.token.clone();
        let auth = ditto.auth();
        async move {
            if let Some(auth) = auth {
                if let Err(e) = auth.login(&token, &get_development_provider()) {
                    println!("Auth failed: {e}");
                }
            }
        }
    }
}

let config = DittoConfig::new(
    "your-database-id",
    DittoConfigConnect::Server {
        url: "https://your-server.ditto.live".parse().unwrap(),
    },
);

let ditto = Ditto::open(config).await?;

let auth = ditto.auth().expect("Auth available in Server mode");
auth.set_expiration_handler(AuthHandler { token: "your-token".to_string() });

ditto.sync().start()?;
```

CHECKLIST:
- Replace Ditto::builder()...build() with Ditto::open(DittoConfig::new(...))
- Create DittoConfig with database_id and DittoConfigConnect variant
- Use .parse().unwrap() for URL fields (url::Url type)
- Implement DittoAuthExpirationHandler on a struct (NOT async move closures with captured state)
- auth.login() is synchronous β€” handle Result directly
- Use get_development_provider() for dev tokens
- Remove update_transport_config (v5 auto-configures websocket from Server URL)
- Remove disable_sync_with_v3, DQL strict mode workarounds
- Replace _v2 method suffixes
- Replace AppId with DatabaseId
- Replace peer_key_string with peer_key
- Replace is_connected_to_ditto_cloud with is_connected_to_ditto_server
- Update ditto.store to ditto.store() (method, not field)
- Replace start_sync()/stop_sync() with sync().start()/sync().stop()

Work through the codebase systematically. Show me each file's changes.

Ditto Instance Initialization

v5 separates initialization into four distinct phases for better clarity and control:
use dittolive_ditto::prelude::*;
use std::future::Future;
use std::time::Duration;

// 1. Configure
let config = DittoConfig::new(
    "your-database-id",
    DittoConfigConnect::Server {
        url: "https://your-server.ditto.live".parse().unwrap(),
    },
);

// 2. Initialize (async)
let ditto = Ditto::open(config).await?;

// 3. Authenticate (struct-based handler β€” see Authentication section for details)
struct AuthHandler { token: String }
impl DittoAuthExpirationHandler for AuthHandler {
    fn on_expiration(&self, ditto: &Ditto, _: Duration) -> impl Send + Future<Output = ()> {
        let token = self.token.clone();
        let auth = ditto.auth();
        async move {
            if let Some(auth) = auth {
                if let Err(e) = auth.login(&token, &get_development_provider()) {
                    println!("Auth failed: {e}");
                }
            }
        }
    }
}

let auth = ditto.auth().expect("Auth available in Server mode");
auth.set_expiration_handler(AuthHandler { token: "your-token".to_string() });

// 4. Start sync
ditto.sync().start()?;
These changes provide:
  • Clear separation of connection, initialization, and authentication concerns
  • Simplified initialization with DittoConfig::new() replacing the builder pattern
  • Struct-based expiration handler for type-safe, re-callable authentication
  • No workarounds needed β€” v5 auto-configures the websocket URL from the Server connect URL, and removes DQL strict mode and v3 sync workarounds
Initialization Migration Steps
1

Replace Initialization

Replace Ditto::builder()...build() with Ditto::open(config).
let config = DittoConfig::new(
    "your-database-id",
    DittoConfigConnect::Server {
        url: "https://your-server.ditto.live".parse().unwrap(),
    },
);

let ditto = Ditto::open(config).await?;
ditto.sync().start()?;
Key changes:
  • Use DittoConfig::new(database_id, connect) instead of DittoBuilder
  • AppId β†’ DatabaseId (or database_id: String in config)
  • OnlinePlayground β†’ DittoConfigConnect::Server { url: Url }
  • OfflinePlayground β†’ DittoConfigConnect::SmallPeersOnly { private_key: None }
  • SharedKey β†’ DittoConfigConnect::SmallPeersOnly { private_key: Some(key_bytes) }
  • URL fields use url::Url type β€” parse from string with .parse().unwrap()
  • Ditto::open() is async, Ditto::open_sync() is the blocking alternative
  • Remove update_transport_config calls β€” v5 automatically configures the websocket URL from the DittoConfigConnect::Server URL
  • Remove disable_sync_with_v3() calls and DQL strict mode workaround queries
2

Update Authentication

Set authentication handler separately after initialization.
Async closure limitation: set_expiration_handler requires a handler callable multiple times (AsyncFn). An async move closure that captures variables from its environment only implements AsyncFnOnce (consumed on first call), so it will not compile. A bare async || without move captures by reference and won’t satisfy the 'static bound.Use a struct-based handler whenever your handler captures state (tokens, config, etc.) β€” which is virtually every real application.
use std::future::Future;

// Define a handler struct to hold any state needed for re-authentication
struct AuthHandler {
    token: String,
}

impl DittoAuthExpirationHandler for AuthHandler {
    fn on_expiration(
        &self,
        ditto: &Ditto,
        _duration_remaining: Duration,
    ) -> impl Send + Future<Output = ()> {
        // Clone captured state into each future so the struct persists across calls
        let token = self.token.clone();
        // Call ditto.auth() synchronously before the async block to avoid lifetime issues
        let auth = ditto.auth();
        async move {
            if let Some(auth) = auth {
                let provider = get_development_provider();
                if let Err(e) = auth.login(&token, &provider) {
                    println!("Auth failed: {}", e);
                }
            }
        }
    }
}

let auth = ditto.auth().expect("Auth available in Server mode");
auth.set_expiration_handler(AuthHandler {
    token: "your-token".to_string(),
});
Key changes:
  • ditto.auth() returns Option<DittoAuthenticator> β€” unwrap with .expect() in Server mode
  • Implement DittoAuthExpirationHandler on a struct to hold captured state (tokens, config)
  • on_expiration takes &self β€” the struct persists across calls; .clone() state into each future
  • Call ditto.auth() synchronously before the async move block to avoid lifetime issues with borrowing ditto
  • auth.login() is synchronous β€” returns Result<AuthenticationClientFeedback, DittoError> directly
  • Use get_development_provider() function for playground tokens
  • Custom auth: pass your provider name as &str to login()

Additional Changes

DQL Strict Mode Behavior Change

Breaking Change: v5 defaults to DQL_STRICT_MODE=false, which fundamentally changes how DQL queries behave.
  • v4 default: Objects treated as REGISTER (whole-object replacement)
  • v5 default: Objects treated as MAP (field-level merging)
This affects the behavior of all DQL SELECT, INSERT, and UPDATE operations.
Choose the appropriate migration path based on your current v4 configuration:
If you’re currently using the v4 default (DQL_STRICT_MODE=true), you must explicitly set strict mode to true in v5 before starting sync or executing any queries.
Failing to set strict mode will cause objects to merge at the field level instead of replacing entirely, which can result in unexpected data behavior and perceived data loss.
To migrate to DQL_STRICT_MODE=false (the new v5 default), contact Ditto Customer Support for guidance.
let config = DittoConfig::new(
    "your-database-id",
    DittoConfigConnect::Server {
        url: "https://your-server.ditto.live".parse().unwrap(),
    },
);
let ditto = Ditto::open(config).await?;

// IMPORTANT: Set strict mode BEFORE starting sync or running queries
ditto.store().execute("ALTER SYSTEM SET DQL_STRICT_MODE = true").await?;

// Now safe to start sync
ditto.sync().start()?;
If you explicitly set DQL_STRICT_MODE=false in v4, no changes are required.v5 uses DQL_STRICT_MODE=false as the default, so your existing DQL queries will behave identically. You can upgrade freely.Optional: You can remove the explicit ALTER SYSTEM SET DQL_STRICT_MODE = false statement in v5 since this is now the default behavior.
let config = DittoConfig::new(
    "your-database-id",
    DittoConfigConnect::Server {
        url: "https://your-server.ditto.live".parse().unwrap(),
    },
);
let ditto = Ditto::open(config).await?;

// No need to set DQL_STRICT_MODE - false is now the default
ditto.sync().start()?;
The Legacy Query Builder has been removed in v5. All queries must be converted to DQL before upgrading.Good news: Legacy Query Builder functionality has 1:1 support with DQL_STRICT_MODE=false (the v5 default), making migrations straightforward.Migration Steps:
  1. In v4: Set DQL_STRICT_MODE=false after initialization:
    let ditto = Ditto::builder()
        .with_root(Arc::new(PersistentRoot::from_current_exe()?))
        .with_identity(|ditto_root| {
            identity::OnlinePlayground::new(ditto_root, "your-app-id", "your-token", false)
        })?
        .build()?;
    
    // Set DQL_STRICT_MODE to false for Query Builder compatibility
    ditto.store().execute_v2("ALTER SYSTEM SET DQL_STRICT_MODE = false", None).await?;
    
    ditto.start_sync()?;
    
  2. Convert all queries from Legacy Query Builder to DQL See the Rust Legacy→DQL Migration Guide for detailed conversion examples.
  3. Upgrade to v5 No DQL configuration changes requiredβ€”v5 defaults to DQL_STRICT_MODE=false.
For additional guidance or questions, contact Ditto Customer Support.

Default Persistence Directory

v5 includes the database ID in the default directory name: ditto-{database_id} instead of ditto To maintain v4 compatibility:
let config = DittoConfig::new(
    "your-database-id",
    DittoConfigConnect::Server {
        url: "https://your-server.ditto.live".parse().unwrap(),
    },
).with_persistence_directory("/app/data/ditto");

API Renames

v4.14v5.0
Ditto::builder()...build()Ditto::open(DittoConfig) (async) or Ditto::open_sync(DittoConfig)
AppIdDatabaseId
ditto.start_sync()ditto.sync().start()
ditto.stop_sync()ditto.sync().stop()
ditto.is_sync_active()ditto.sync().is_active()
store.execute_v2(query, args)store.execute(query) (accepts IntoQuery trait)
store.register_observer_v2(...)store.register_observer(...)
store.register_observer_with_signal_next_v2(...)store.register_observer_with_signal_next(...)
DiskUsageChildDiskUsageItem
Ditto::with_sdk_version(|v| {...})Ditto::version() (static, returns String)
Peer.is_connected_to_ditto_cloudPeer.is_connected_to_ditto_server
Peer.peer_key_string + Peer.peer_key: Vec<u8>Peer.peer_key: String (unified)
Connection.peer_key_string1/2Connection.peer1/2
DittoLogger::set_emoji_log_level_headings_enabled(...)Removed
disable_sync_with_v3()Removed
WriteStrategy enumRemoved β€” DQL handles write semantics

AppId β†’ DatabaseId

// v4
use ditto::AppId;
let app_id = AppId::from_str("my-app-id")?;

// v5
use ditto::DatabaseId;
let database_id = DatabaseId::from_str("my-database-id")?;
Update all AppId type references and variable names throughout your codebase.

_v2 Suffix Removed

All _v2 method variants are now the canonical API β€” the suffix is dropped:
// v4
store.execute_v2("SELECT * FROM cars", None).await?;
store.register_observer_v2("SELECT * FROM cars", None, |result| { ... })?;

// v5
store.execute("SELECT * FROM cars").await?;
store.register_observer("SELECT * FROM cars", |result| { ... })?;
Note: v5 store.execute() uses the IntoQuery trait. Pass a string for queries without args, or a tuple (query, args) for parameterized queries where args implements serde::Serialize.

Back Pressure (register_observer_with_signal_next)

The standard register_observer automatically calls signal_next() after your handler returns, and callbacks are never concurrent β€” one callback must return before the next fires. For handlers that do expensive work before they’re ready for the next update, use register_observer_with_signal_next to call signal_next when you choose.
Coalescing behavior: While your handler is processing, Ditto coalesces intermediate changes. When you call signal_next(), you receive a single callback with the latest state β€” not every intermediate change.
// Standard observer β€” signal_next called automatically after handler returns
let _observer = store.register_observer(
    "SELECT * FROM cars WHERE color = 'blue'",
    move |query_result| {
        for item in query_result.iter() {
            process_item(item);
        }
        // signal_next called automatically here
    },
)?;

// Back-pressure observer β€” call signal_next when ready
let _observer = store.register_observer_with_signal_next(
    "SELECT * FROM cars WHERE color = 'blue'",
    move |query_result, signal_next| {
        for item in query_result.iter() {
            expensive_process(item);
        }
        // Call when ready for the next callback
        signal_next();
    },
)?;

APIs Removed Without Replacement

Removed APINotes
disable_sync_with_v3()v3 protocol support fully dropped
DittoLogger::set_emoji_log_level_headings_enabled(...)Emoji log heading configuration removed
WriteStrategy enumDQL ON ID CONFLICT handles write semantics
DittoBuilderReplaced entirely by Ditto::open(DittoConfig)
Connection.approximate_distance_in_metersPeer distance estimation removed
Identity trait + individual identity structsReplaced by DittoConfigConnect enum

New APIs in v5

  • Ditto::open(DittoConfig) β€” async factory replacing DittoBuilder
  • Ditto::open_sync(DittoConfig) β€” synchronous variant
  • DatabaseId β€” replaces AppId
  • DittoConfig::new(database_id, connect) β€” unified config with system_parameters support
  • DittoConfigConnect::Server { url: Url } / DittoConfigConnect::SmallPeersOnly { private_key: Option<Vec<u8>> } β€” connect mode variants
  • ditto.sync().start() / .stop() / .is_active() β€” sync lifecycle on Sync object
  • ditto.version() β€” static method, replaces Ditto::with_sdk_version()
  • DiskUsageItem β€” replaces DiskUsageChild
  • DittoLogger::set_custom_log_callback() β€” custom log callback support
  • get_development_provider() β€” returns the development auth provider name
  • store.execute() and store.register_observer() as canonical DQL API (no more _v2 suffix)

Migration Checklist

Initialization

  • Replace Ditto::builder()...build() with Ditto::open(config).await?
  • Create DittoConfig::new(database_id, connect) with appropriate connect mode
  • Update OnlinePlayground β†’ DittoConfigConnect::Server { url: "...".parse().unwrap() }
  • Update OfflinePlayground β†’ DittoConfigConnect::SmallPeersOnly { private_key: None }
  • Update SharedKey β†’ DittoConfigConnect::SmallPeersOnly { private_key: Some(key_bytes) }
  • Remove PersistentRoot / with_root() setup
  • Remove update_transport_config calls
  • Remove disable_sync_with_v3() calls
  • Set DQL_STRICT_MODE=true BEFORE starting sync if maintaining v4 behavior

Authentication

  • Get authenticator: let auth = ditto.auth().expect("Auth available in Server mode")
  • Implement DittoAuthExpirationHandler on a struct to hold captured state (do NOT use async move closures with captured variables)
  • auth.login() is synchronous β€” returns Result<AuthenticationClientFeedback, DittoError>
  • Use get_development_provider() for playground tokens
  • Remove authentication from identity configuration

Sync

  • Update ditto.start_sync() β†’ ditto.sync().start()
  • Update ditto.stop_sync() β†’ ditto.sync().stop()
  • Update ditto.is_sync_active() β†’ ditto.sync().is_active()

Data Operations

  • Replace all execute_v2() calls with execute()
  • Replace all register_observer_v2() calls with register_observer()
  • Replace all register_observer_with_signal_next_v2() with register_observer_with_signal_next()
  • Use parameterized queries β€” never string formatting
  • Replace collection-based operations with DQL via store().execute()

Breaking Changes

  • Set with_persistence_directory() if maintaining v4 directory structure
  • Update peer_key_string β†’ peer_key: String (unified, no more Vec<u8> variant)
  • Update peer_key_string1/2 β†’ peer1/2
  • Update is_connected_to_ditto_cloud β†’ is_connected_to_ditto_server
  • Access store via ditto.store() method (parentheses required)
  • Replace AppId β†’ DatabaseId (type rename throughout)
  • Replace DiskUsageChild β†’ DiskUsageItem
  • Replace Ditto::with_sdk_version(|v| {...}) β†’ Ditto::version()
  • Remove Connection.approximate_distance_in_meters usage

Verification

  • Build compiles with zero errors (cargo build)
  • No deprecated API warnings
  • Observers update data immediately
  • Authentication works before sync starts
  • No memory leaks

Common Pitfalls

  1. DQL_STRICT_MODE silent change: Not setting DQL_STRICT_MODE=true when your v4 app used the default. Objects that were replaced whole will now merge at field level β€” causes unexpected data merging.
  2. ditto.auth() returns Option: In Server mode, auth() returns Some(authenticator). In SmallPeersOnly mode, it returns None. Always .expect() or match when you know you’re in Server mode.
  3. auth.login() is synchronous: Unlike other SDKs, the Rust login() returns Result directly β€” no callback or async. But the expiration handler itself IS async.
  4. URL type for Server connect: DittoConfigConnect::Server { url } uses url::Url, not String. Parse with "https://...".parse().unwrap().
  5. String interpolation in queries: Never format!("SELECT * FROM cars WHERE color = '{}'", color). Use the IntoQuery trait: store().execute(("SELECT * FROM cars WHERE color = :color", json!({"color": color}))).await?.
  6. Counter initialization: Don’t put 0 in insert documents for counter fields. Counters are created on first PN_INCREMENT. Inserting 0 creates a REGISTER, not a COUNTER.
  7. Missing ON ID CONFLICT: INSERT fails if document _id already exists without a conflict clause.
  8. async move closures in set_expiration_handler: The handler must be callable multiple times (AsyncFn), but async move closures that capture variables only implement AsyncFnOnce. Implement DittoAuthExpirationHandler on a struct instead β€” see the Authentication section above.