> ## 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.

# Rust V4→V5 API Migration Guide

> Guide for migrating Rust apps from Ditto v4 to v5.

## 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](./rust-dql) 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.

<Accordion title="Copy AI Migration Prompt (Click to Expand)">
  ````text theme={null}
  I need help migrating a Ditto Rust application from v4 to v5. Focus on these critical changes:

  TOOLCHAIN:
  dittolive-ditto v5 requires rustc 1.91 or later. Add a rust-toolchain.toml to the
  project root so the correct toolchain is selected automatically:
    [toolchain]
    channel = "1.91"

  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 value implementing DittoAuthExpirationHandler
  - RECOMMENDED: Implement DittoAuthExpirationHandler on a struct that holds captured state (token, config)
    - on_expiration is a plain async fn taking &self — clone fields inside the method body as needed
    - Do NOT write fn on_expiration(...) -> impl Send + Future<Output = ()> — use async fn directly
  - 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)

  IMPORTS:
  DittoAuthExpirationHandler, DittoConfig, and DittoConfigConnect are NOT in submodules.
  They are re-exported directly from the crate root. Do NOT write dittolive_ditto::auth::
  or dittolive_ditto::config:: — those modules do not exist in v5.
  - Correct: use dittolive_ditto::{DittoAuthExpirationHandler, DittoConfig, DittoConfigConnect, DatabaseId, Ditto};
  - Correct: use dittolive_ditto::identity::get_development_provider;
  - Do NOT add `use std::future::Future` — the trait method is async fn, no manual Future type needed

  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
  - DatabaseId does NOT implement Into<String>. DittoConfig::new takes S: Into<String>,
    so if you have a DatabaseId variable (e.g. parsed from CLI or env), call .to_string() on it:
    let config = DittoConfig::new(app_id.to_string(), connect);
  - 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
  struct AuthHandler { token: String }

  impl DittoAuthExpirationHandler for AuthHandler {
      async fn on_expiration(&self, ditto: &Ditto, _: Duration) {
          let token = self.token.clone();
          if let Some(auth) = ditto.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:
  - Add rust-toolchain.toml with channel = "1.91" (v5 requires rustc 1.91)
  - 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)
  - Import DittoAuthExpirationHandler, DittoConfig, DittoConfigConnect from crate root (no submodule path)
  - Do NOT import std::future::Future — not needed for async fn trait methods
  - Implement DittoAuthExpirationHandler on a struct using async fn on_expiration(&self, ...)
  - 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
  - If app_id is a DatabaseId variable, pass app_id.to_string() to DittoConfig::new — DatabaseId does not impl Into<String> (string literals work directly)
  - 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()/is_sync_active() with sync().start()/sync().stop()/sync().is_active()

  Work through the codebase systematically. Show me each file's changes.
  ````
</Accordion>

***

## Ditto Instance Initialization

v5 separates initialization into four distinct phases for better clarity and control:

<CodeGroup>
  ```rust RUST (v5) theme={null}
  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()?;
  ```

  ```rust RUST (v4) theme={null}
  use dittolive_ditto::prelude::*;

  // Everything mixed together via builder
  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, // enable_ditto_cloud_sync
          )
      })?
      .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()?;
  ```
</CodeGroup>

**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**

<Steps>
  <Step title="Replace Initialization">
    Replace `Ditto::builder()...build()` with `Ditto::open(config)`.

    <CodeGroup>
      ```rust RUST (v5) theme={null}
      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()?;
      ```

      ```rust RUST (v4) theme={null}
      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.start_sync()?;
      ```
    </CodeGroup>

    **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
  </Step>

  <Step title="Update Authentication">
    Set authentication handler separately after initialization.

    <Warning>
      **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.
    </Warning>

    <CodeGroup>
      ```rust RUST (v5) theme={null}
      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(),
      });
      ```

      ```rust RUST (v4) theme={null}
      // Auth mixed with identity via builder
      let ditto = Ditto::builder()
          .with_identity(|ditto_root| {
              identity::OnlinePlayground::new(
                  ditto_root,
                  "your-app-id",
                  "your-token", // Token embedded in identity
                  false,
              )
          })?
          .build()?;
      ```
    </CodeGroup>

    **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()`
  </Step>
</Steps>

## Additional Changes

### DQL Strict Mode Behavior Change

<Warning>
  **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.
</Warning>

Choose the appropriate migration path based on your current v4 configuration:

<AccordionGroup>
  <Accordion title="Currently Using DQL with DQL_STRICT_MODE=true (v4 default)">
    **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.

    <Warning>
      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.
    </Warning>

    <Note>
      To migrate to `DQL_STRICT_MODE=false` (the new v5 default), contact Ditto Customer Support for guidance.
    </Note>

    <CodeGroup>
      ```rust Rust (v5 - Maintain v4 Behavior) highlight={5,6} theme={null}
      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()?;
      ```
    </CodeGroup>
  </Accordion>

  <Accordion title="Currently Using DQL with DQL_STRICT_MODE=false">
    **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.

    <CodeGroup>
      ```rust Rust (v5) theme={null}
      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()?;
      ```

      ```rust Rust (v4 - Your Current Setup) theme={null}
      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()?;

      // You explicitly set this in v4
      ditto.store().execute_v2("ALTER SYSTEM SET DQL_STRICT_MODE = false", None).await?;

      ditto.start_sync()?;
      ```
    </CodeGroup>
  </Accordion>

  <Accordion title="Currently Using Legacy Query Builder">
    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:
           <CodeGroup>
             ```rust Rust (v4 - Enable Non-Strict Mode) theme={null}
             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()?;
             ```
           </CodeGroup>

    2. **Convert all queries from Legacy Query Builder to DQL**
       See the [Rust Legacy→DQL Migration Guide](./rust-dql) for detailed conversion examples.

    3. **Upgrade to v5**
       No DQL configuration changes required—v5 defaults to `DQL_STRICT_MODE=false`.
  </Accordion>
</AccordionGroup>

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:**

```rust theme={null}
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.14                                                    | v5.0                                                                  |
| -------------------------------------------------------- | --------------------------------------------------------------------- |
| `Ditto::builder()...build()`                             | `Ditto::open(DittoConfig)` (async) or `Ditto::open_sync(DittoConfig)` |
| `AppId`                                                  | `DatabaseId`                                                          |
| `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(...)`                       |
| `DiskUsageChild`                                         | `DiskUsageItem`                                                       |
| `Ditto::with_sdk_version(\|v\| {...})`                   | `Ditto::version()` (static, returns String)                           |
| `Peer.is_connected_to_ditto_cloud`                       | `Peer.is_connected_to_ditto_server`                                   |
| `Peer.peer_key_string` + `Peer.peer_key: Vec<u8>`        | `Peer.peer_key: String` (unified)                                     |
| `Connection.peer_key_string1/2`                          | `Connection.peer1/2`                                                  |
| `DittoLogger::set_emoji_log_level_headings_enabled(...)` | Removed                                                               |
| `disable_sync_with_v3()`                                 | Removed                                                               |
| `WriteStrategy` enum                                     | Removed — DQL handles write semantics                                 |

### AppId → DatabaseId

```rust theme={null}
// 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:

```rust theme={null}
// 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.

<Note>
  **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.
</Note>

```rust theme={null}
// 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 API                                              | Notes                                           |
| -------------------------------------------------------- | ----------------------------------------------- |
| `disable_sync_with_v3()`                                 | v3 protocol support fully dropped               |
| `DittoLogger::set_emoji_log_level_headings_enabled(...)` | Emoji log heading configuration removed         |
| `WriteStrategy` enum                                     | DQL `ON ID CONFLICT` handles write semantics    |
| `DittoBuilder`                                           | Replaced entirely by `Ditto::open(DittoConfig)` |
| `Connection.approximate_distance_in_meters`              | Peer distance estimation removed                |
| `Identity` trait + individual identity structs           | Replaced 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.

***
