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 Swift app from v4 to v5. The main architectural shift is moving from identity-based initialization to a four-phase configuration model with async/await APIs. 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 Swift app from v4 to v5.
I need help migrating a Ditto Swift application from v4 to v5. Focus on these critical changes:

INITIALIZATION:


- In v4 when adding the `DittoSwift` package, it would add in the `DittoObjC` package product dependencies.  In v5, `DittoObjC` is no longer a separate product β€” it's bundled into `DittoSwift`.  This means that you no longer need to add the `DittoObjC` package product dependencies to your project and you need to remove the `DittoObjC` package product dependencies from your project in order to get the project to build.
- These are the following places the `DittoObjC` package product dependencies are usually used:
    - 1. `PBXBuildFile` section β€” removed the `DittoObjC in Frameworks` entry
    - 2. `PBXFrameworksBuildPhase` β€” removed `DittoObjC in Frameworks` from the `files` array
    - 3. `PBXNativeTarget.packageProductDependencies` β€” removed the `DittoObjC` reference
    - 4. `XCSwiftPackageProductDependency` β€” removed the entire `DittoObjC` dependency entry
- Replace `Ditto(identity:)` with `try await Ditto.open(config:)` using async/await.
- Create a `DittoConfig` with `databaseID` and a connect mode
- `.onlinePlayground(appID:token:enableDittoCloudSync:customAuthURL:)` β†’ `DittoConfig(databaseID: appID, connect: .server(url:))`
- `.onlineWithAuthentication(appID:enableDittoCloudSync:customAuthURL:)` β†’ `DittoConfig(databaseID: appID, connect: .server(url:))` + auth `expirationHandler`
- `.offlinePlayground(appID:)` β†’ `DittoConfig(databaseID: appID, connect: .smallPeersOnly(privateKey: nil))`
- `.sharedKey(appID:sharedKey:)` β†’ `DittoConfig(databaseID: appID, connect: .smallPeersOnly(privateKey: sharedKey))`
- `.manual(certificateConfig:)` β†’ **No replacement** β€” removed in v5
- The `Ditto(identity:)` initializer is removed. You MUST use `try await Ditto.open(config:)` or `Ditto.openSync(config:)`
- Remove `disableSyncWithV3()` calls β€” v3 protocol is fully dropped in v5
- Remove `updateTransportConfig` workaround patterns for adding websocket URLs β€” `DittoConfig` handles the server URL directly. Note: `updateTransportConfig` itself still exists in v5 for advanced transport configuration
- Remove `ditto.store.execute(query: "ALTER SYSTEM SET DQL_STRICT_MODE = false")` β€” v5 defaults to `false`
- IMPORTANT: If your v4 app used the default (strict=true), set `DQL_STRICT_MODE=true` BEFORE starting sync to maintain v4 behavior. Objects that were replaced whole in v4 will silently merge at field level in v5 without this.
- v5 default persistence directory is `ditto-{databaseID}` (was `ditto` in v4). Set `persistenceDirectory` explicitly if maintaining v4 directory structure

SYNC:
The sync API moved from top-level Ditto methods to the `ditto.sync` sub-object.
- `try ditto.startSync()` β†’ `try ditto.sync.start()`
- `ditto.stopSync()` β†’ `ditto.sync.stop()`
- `ditto.isSyncActive` β†’ `ditto.sync.isActive`

AUTHENTICATION:
The `DittoAuthenticationDelegate` protocol is removed. Replace with the `expirationHandler` closure.
- Set `ditto.auth?.expirationHandler = { expiredDitto, timeUntilExpiration in ... }` after `Ditto.open(config:)`
- The handler signature is `@Sendable (_ ditto: Ditto, _ timeUntilExpiration: TimeInterval) async -> Void`
- Inside the handler, call `expiredDitto.auth?.login(token:provider:completion:)` (use a distinct closure parameter name to avoid shadowing the outer `ditto`)
- Use `.development` provider for playground/test tokens
- The handler is called when `timeUntilExpiration == 0` (initial auth or expired) and when `timeUntilExpiration > 0` (token expiring soon β€” refresh). Handle both cases.
- `authenticator.loginWithToken(_:provider:completion:)` β†’ `ditto.auth?.login(token:provider:completion:)`
- `DittoAuthenticationDelegate.authenticationRequired(authenticator:)` β†’ removed, use `expirationHandler` with `timeUntilExpiration == 0`
- `DittoAuthenticationDelegate.authenticationExpiringSoon(authenticator:secondsRemaining:)` β†’ removed, use `expirationHandler` with `timeUntilExpiration > 0`
- `ditto.delegate = MyAuthDelegate()` β†’ `ditto.auth?.expirationHandler = { ... }`

OBSERVERS:
The callback-based `registerObserver` API is the same in v5 β€” it returns `DittoStoreObserver` with callback handlers.
- `ditto.store.registerObserver(query:arguments:deliverOn:handler:)` β†’ same API, returns `DittoStoreObserver`
- v5 adds `Codable` argument overloads: `registerObserver(query:arguments: Codable, deliverOn:handler:)`
- Call `item.dematerialize()` after extracting data from `DittoQueryResultItem` to free native memory β€” do not use the item after this call
- `observer.stop()` / `observer.cancel()` for cleanup is still the pattern

BACK PRESSURE (Signal Next):
Use `registerObserver(query:arguments:deliverOn:handlerWithSignalNext:)` when your handler needs time to process results.
- Changes that occur while your handler is processing are coalesced β€” you get one callback with the latest state, not every intermediate change
- You must call `signalNext()` to receive the next update
- Standard observer auto-signals after handler returns

COLLECTION API β†’ DQL:
All `.collection()` APIs are **removed**. Replace with `store.execute()` using DQL.
- `ditto.store.collection("cars").find("color == 'blue'").exec()` β†’ `try await ditto.store.execute(query: "SELECT * FROM cars WHERE color = :color", arguments: ["color": "blue"])`
- `ditto.store.collection("cars").findByID(id).exec()` β†’ `try await ditto.store.execute(query: "SELECT * FROM cars WHERE _id = :id", arguments: ["id": id])`
- `ditto.store.collection("cars").upsert(value)` β†’ `try await ditto.store.execute(query: "INSERT INTO cars DOCUMENTS (:doc) ON ID CONFLICT DO UPDATE", arguments: ["doc": value])`
- `ditto.store.collection("cars").findByID(id).update { doc in doc?["color"].set("green") }` β†’ `try await ditto.store.execute(query: "UPDATE cars SET color = :color WHERE _id = :id", arguments: ["color": "green", "id": id])`
- `ditto.store.collection("cars").findByID(id).remove()` β†’ `try await ditto.store.execute(query: "DELETE FROM cars WHERE _id = :id", arguments: ["id": id])`
- `ditto.store.collection("cars").findByID(id).evict()` β†’ `try await ditto.store.execute(query: "EVICT FROM cars WHERE _id = :id", arguments: ["id": id])`
- Always use parameterized queries with `:paramName` β€” NEVER use string interpolation in DQL queries

LIVE QUERIES β†’ STORE OBSERVERS:
- `collection.find("...").observeLocal { docs, event in ... }` β†’ `ditto.store.registerObserver(query: "SELECT * FROM ...", arguments: [...]) { result in ... }`
- `liveQuery?.stop()` β†’ `observer.stop()`
- v4 handler: `(docs: [DittoDocument], event: DittoLiveQueryEvent) -> Void`
- v5 handler: `(result: DittoQueryResult) -> Void` where result has `.items` containing `DittoQueryResultItem` objects
- Extract your model from `item.jsonData()` then call `item.dematerialize()` to free native memory
- When using `item.jsonData()`, you need to make sure you import the Swift Foundation framework

SUBSCRIPTIONS:
- `ditto.store.collection("cars").find("color == 'red'").subscribe()` β†’ `try ditto.sync.registerSubscription(query: "SELECT * FROM cars WHERE color = :color", arguments: ["color": "red"])`
- `DittoSubscription` β†’ replaced by `DittoSyncSubscription`

COUNTERS:
- `doc?["viewCount"].counter?.increment(by: 1.0)` β†’ `try await ditto.store.execute(query: "UPDATE cars APPLY viewCount PN_INCREMENT BY :n WHERE _id = :id", arguments: ["n": 1, "id": id])`
- DO NOT initialize counter fields in insert documents. Counters are created on first `PN_INCREMENT`. Inserting `0` creates a REGISTER, not a COUNTER.
- Decrement by passing a negative value: `["n": -1]`

PRESENCE:
- `peer.peerKeyString` β†’ `peer.peerKey` (type is `String` in both v4 and v5, but property renamed)
- `peer.osV2` β†’ `peer.os`
- `peer.isConnectedToDittoCloud` β†’ `peer.isConnectedToDittoServer`
- `connection.peerKeyString1` / `peerKeyString2` β†’ `connection.peer1` / `connection.peer2` (now `String`)
- `connection.approximateDistanceInMeters` β†’ **removed, no replacement**
- `ditto.observePeers(_:)` β†’ `ditto.presence.observe(didChangeHandler:)`
- `DittoRemotePeer` struct (deprecated) β†’ use `DittoPeer` in `DittoPresenceGraph`
- `DittoAddress` struct β†’ removed

DITTO CLASS:
- `Ditto(identity:)` β†’ `try await Ditto.open(config:)` or `Ditto.openSync(config:)`
- `ditto.activated` β†’ `ditto.isActivated`
- `ditto.appID` β†’ `ditto.config.databaseID`
- `ditto.transportDiagnostics()` β†’ removed
- `ditto.runGarbageCollection()` β†’ removed (automatic in v5)
- `ditto.disableSyncWithV3()` β†’ removed (v3 protocol dropped)
- `ditto.isHistoryTrackingEnabled` β†’ removed

DISK USAGE:
- `DiskUsage` β†’ `DittoDiskUsage`
- `diskUsage.exec` β†’ `diskUsage.item`
- `DiskUsageObserverHandle` β†’ `DittoDiskUsageObserver`
- `DiskUsageItem` β†’ `DittoDiskUsageItem` (property names `.path`, `.sizeInBytes`, `.childItems` are unchanged)

LOGGING:
- `DittoLogger` class name is unchanged in both v4 and v5, but is now `final` and `Sendable` in v5
- `DittoLogger.setLogFile()` β†’ removed β€” use `export(to:)` instead

ERRORS:
- `DittoSwiftError` typealias β†’ removed, use `DittoError` directly
- `migrationError` error case β†’ removed

TRANSPORT CONFIG:
- `DittoHTTPListenConfig.staticContentPath` β†’ removed
- Default `webSocketSync` β€” check if your app relied on v4 default; explicitly set `true` if needed

TYPES & PROTOCOLS:
- `DittoConnectionPriority` enum β†’ removed
- `DittoTransportSnapshot` struct β†’ removed (with transport diagnostics)
- `DittoExperimental.jsonByTranscoding(cbor:)` β†’ removed
- All public types are now `final` and `Sendable` for Swift concurrency
- `DittoConfig` and `DittoConfigConnect` conform to `Sendable` and `Codable`
- `DittoConnection` now conforms to `Identifiable`, `Equatable`, `Hashable`, and `Sendable`

SWIFT 6 COMPATIBILITY:
- `DittoConnection` gaining `Identifiable` conformance causes `filter` ambiguity in Swift 6. Fix: annotate closure parameter explicitly:
  ```swift
  // BROKEN in Swift 6
  connections.filter { $0.peer1 == localKey }
  // FIXED
  connections.filter { (conn: DittoConnection) in conn.peer1 == localKey }
  ```
- Auth handler: `expirationHandler` closure is `@Sendable` and `async`. If your Ditto manager is an `actor`, use `await` when accessing actor-isolated state inside `Task { }` blocks within the handler.

REMOVED APIs (no replacement):
- `ditto.transportDiagnostics()` β€” low-level transport debug info
- `ditto.runGarbageCollection()` β€” CRDT GC controls
- `ditto.disableSyncWithV3()` β€” v3 protocol fully dropped
- `ditto.isHistoryTrackingEnabled` β€” history tracking removed
- `DittoIdentity.manual(certificateConfig:)` β€” manual identity type
- `Connection.approximateDistanceInMeters` β€” peer distance estimation
- `DittoHTTPListenConfig.staticContentPath` β€” static file serving
- `DittoStore.queriesHash(queries:)` β€” hash-based query deduplication
- `DittoStore.queriesHashMnemonic(queries:)` β€” query mnemonic hashing
- `DittoConnectionPriority` enum β€” connection priority
- `DittoTransportSnapshot` struct β€” removed with transport diagnostics
- `DittoRemotePeer` struct (deprecated) β€” use `DittoPeer` in `DittoPresenceGraph`
- `DittoAddress` struct β€” removed
- `DittoExperimental.jsonByTranscoding(cbor:)` β€” CBOR-to-JSON utility
- `DittoSwiftError` typealias β€” use `DittoError` directly
- `migrationError` error case β€” removed
- `DittoLogger.setLogFile()` β€” use `export(to:)` instead

NEW APIs:
- `Ditto.open(config:): async throws -> Ditto` β€” async factory method
- `Ditto.openSync(config:): throws -> Ditto` β€” synchronous factory alternative
- `ditto.config: DittoConfig` β€” read the config used to open this instance
- `ditto.isActivated: Bool` β€” replaces `ditto.activated`
- `DittoStore.newAttachment(data: Data, metadata:)` β€” create attachment from in-memory `Data` (v4 was file path only)
- `DittoStore.registerObserver(query:arguments: some Codable, ...)` β€” overloads accepting `Codable` query arguments
- `DittoSync.registerSubscription(query:arguments: some Codable)` β€” overload accepting `Codable` arguments
- `DittoDiskUsageObserver` β€” standalone observer class (was nested `DiskUsage.DiskUsageObserverHandle`)
- `DittoDiskUsageItem` β€” renamed from `DiskUsageItem`
- `DittoSyncSubscription.queryArgumentsCBORData` / `.queryArgumentsJSONData` / `.id` β€” introspection properties
- `DittoConnection` conforms to `Identifiable`, `Equatable`, `Hashable`, and `Sendable`
- All public types now `final` and `Sendable` for Swift concurrency
- `DittoConfig` and `DittoConfigConnect` conform to `Sendable` and `Codable`
- `DittoSmallPeerInfo.metadataJSONString` removed β€” use `metadataJSONData` instead (which already existed in v4)

BEFORE (v4):
```swift
let ditto = Ditto(
    identity: .onlinePlayground(
        appID: "your-app-id",
        token: "your-token",
        enableDittoCloudSync: false,
        customAuthURL: URL(string: "https://your-server.ditto.live")
    )
)

ditto.updateTransportConfig { config in
    config.connect.webSocketURLs.insert("wss://...")
}
ditto.disableSyncWithV3()
try? ditto.store.execute(query: "ALTER SYSTEM SET DQL_STRICT_MODE = false")
try? ditto.startSync()

// Observer β€” callback with manual cleanup
var observer: DittoStoreObserver?
observer = ditto.store.registerObserver(
    query: "SELECT * FROM cars WHERE color = :color",
    arguments: ["color": "red"]
) { result in
    DispatchQueue.main.async {
        self.cars = result.items.compactMap { Car($0.jsonData()) }
    }
}

// Must manually stop in deinit
deinit { observer?.stop() }

// Presence
ditto.observePeers { peers in
    for peer in peers {
        print(peer.peerKeyString, peer.isConnectedToDittoCloud)
    }
}

// Cleanup
observer?.stop()
ditto.stopSync()
```

AFTER (v5):
```swift
let config = DittoConfig(
    databaseID: "your-database-id",
    connect: .server(url: URL(string: "https://your-server.ditto.live")!)
)
let ditto = try await Ditto.open(config: config)

// Authentication
ditto.auth?.expirationHandler = { expiredDitto, timeUntilExpiration in
    expiredDitto.auth?.login(token: "your-token", provider: .development) { clientInfo, error in
        if let error = error {
            print("Ditto auth failed: \(error), client info: \(String(describing: clientInfo)), time until expiration: \(timeUntilExpiration)s")
        }
    }
}

// If maintaining v4 behavior, set strict mode BEFORE starting sync
// try await ditto.store.execute(query: "ALTER SYSTEM SET DQL_STRICT_MODE = true")

try ditto.sync.start()

// Observer β€” callback-based, same pattern as v4 but with dematerialize()
let observer = try ditto.store.registerObserver(
    query: "SELECT * FROM cars WHERE color = :color",
    arguments: ["color": "red"]
) { result in
    let cars = result.items.compactMap { item -> Car? in
        let car = Car(item.jsonData())
        item.dematerialize()  // Free native memory β€” do not use item after this
        return car
    }
    DispatchQueue.main.async { self.cars = cars }
}
// Cleanup: observer.stop()

// Subscription β€” DQL-based
let sub = try ditto.sync.registerSubscription(
    query: "SELECT * FROM cars WHERE color = :color",
    arguments: ["color": "red"]
)

// Presence
ditto.presence.observe { graph in
    let allPeers = [graph.localPeer] + graph.remotePeers
    for peer in allPeers {
        print(peer.peerKey, peer.isConnectedToDittoServer)
    }
}

// Cleanup
sub.cancel()
ditto.sync.stop()
```

CHECKLIST:

Initialization:
- Replace `Ditto(identity:)` with `try await Ditto.open(config:)`
- Add `try await` for initialization β€” `Ditto.open(config:)` is async and throws
- Create `DittoConfig` with `databaseID` and connect mode (`.server(url:)` or `.smallPeersOnly(privateKey:)`)
- Set `DQL_STRICT_MODE=true` BEFORE `sync.start()` if maintaining v4 strict behavior
- Set `persistenceDirectory` if maintaining v4 directory structure
- Remove `disableSyncWithV3()` calls
- Remove `updateTransportConfig` workaround patterns for websocket URLs (method still exists for advanced use)
- Remove `ditto.store.execute(query: "ALTER SYSTEM SET DQL_STRICT_MODE = false")` (v5 default)

Authentication:
- Remove `DittoAuthenticationDelegate` conformance and class
- Remove `ditto.delegate = MyAuthDelegate()` assignment
- Set `ditto.auth?.expirationHandler` closure after `Ditto.open(config:)`
- Handler signature is `@Sendable (_ ditto: Ditto, _ timeUntilExpiration: TimeInterval) async -> Void`
- Use `timeUntilExpiration == 0` check for initial auth vs `> 0` for refresh
- Use `.development` provider for playground/test tokens

Sync:
- Replace `try ditto.startSync()` with `try ditto.sync.start()`
- Replace `ditto.stopSync()` with `ditto.sync.stop()`
- Replace `ditto.isSyncActive` with `ditto.sync.isActive`

Observers:
- Replace `collection.observeLocal { }` with `store.registerObserver(query:arguments:handler:)`
- Call `item.dematerialize()` after extracting data from `DittoQueryResultItem`
- Use `handlerWithSignalNext:` for back-pressure control on expensive operations
- Cleanup with `observer.stop()`

Data Operations:
- Replace all `.collection("x").find(...)` chains with `store.execute(query:arguments:)` DQL
- Use parameterized queries with `:paramName` β€” never string interpolation
- Replace `.upsert()` with `INSERT INTO ... DOCUMENTS (:doc) ON ID CONFLICT DO UPDATE`
- Replace `.update { }` closures with `UPDATE SET` DQL
- Replace `.remove()` with `DELETE FROM`
- Replace `.evict()` with `EVICT FROM`

DittoObjC Removal

In v4 when adding the DittoSwift package, it would add in the DittoObjC package product dependencies. In v5, DittoObjC is no longer a separate product β€” it’s bundled into DittoSwift. This means that you no longer need to add the DittoObjC package product dependencies to your project and you need to remove the DittoObjC package product dependencies from your project in order to get the project to build. These are the following places the DittoObjC package product dependencies are used:
  1. PBXBuildFile section β€” removed the DittoObjC in Frameworks entry
  2. PBXFrameworksBuildPhase β€” removed DittoObjC in Frameworks from the files array
  3. PBXNativeTarget.packageProductDependencies β€” removed the DittoObjC reference
  4. XCSwiftPackageProductDependency β€” removed the entire DittoObjC dependency entry

Ditto Instance Initialization

v5 separates initialization into four distinct phases for better clarity and control:
// 1. Configure
let config = DittoConfig(
    databaseID: "your-database-id",
    connect: .server(url: URL(string: "https://your-server.ditto.live")!)
)

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

// 3. Authenticate
ditto.auth?.expirationHandler = { expiredDitto, timeUntilExpiration in
    expiredDitto.auth?.login(token: "your-token", provider: .development) { clientInfo, error in
        if let error = error {
            print("Ditto auth failed: \(error), client info: \(String(describing: clientInfo)), time until expiration: \(timeUntilExpiration)s")
        }
    }
}

// 4. Start sync
try ditto.sync.start()

These changes provide:
  • Clear separation of connection, initialization, and authentication concerns
  • Async initialization prevents blocking the main thread
  • No workarounds needed (transport config, DQL strict mode, v3 sync disable)
  • Type-safe configuration with compile-time validation
  • Ditto.open() returns a non-optional Ditto instance
Initialization Migration Steps
1

Replace Configuration and Initialization

Replace Ditto(identity:) constructor with Ditto.open(config:).
let config = DittoConfig(
    databaseID: "your-database-id",
    connect: .server(url: URL(string: "https://your-server.ditto.live")!)
)
let ditto = try await Ditto.open(config: config)
Key changes:
  • Use DittoConfig instead of DittoIdentity
  • appID β†’ databaseID
  • .onlinePlayground β†’ .server(url:)
  • .offlinePlayground β†’ .smallPeersOnly(privateKey: nil)
  • Add try await for async initialization
  • Remove updateTransportConfig, disableSyncWithV3(), and DQL strict mode queries
2

Update Authentication

Set authentication handler separately after initialization.
// Set handler after Ditto.open()
// The handler is async and receives the Ditto instance as its first parameter
ditto.auth?.expirationHandler = { expiredDitto, timeUntilExpiration in
        // Initial auth
        expiredDitto.auth?.login(
            token: "your-token",
            provider: .development
        ) { clientInfo, error in
            if let error = error {
                print("Ditto auth failed: \(error), client info: \(String(describing: clientInfo))")
            }
        }
}
Key changes:
  • Authentication now uses expirationHandler closure
  • The handler signature is @Sendable (_ ditto: Ditto, _ timeUntilExpiration: TimeInterval) async -> Void
  • Handler called when auth required (timeUntilExpiration == 0) or token expiring
  • Use .development provider for playground tokens
  • Custom auth: extend DittoAuthenticationProvider with a static property, or use DittoAuthenticationProvider("your-provider")
3

Start Sync

Start sync after initialization.
try ditto.sync.start()
Key changes:
  • Namespace change from ditto.startSync() to ditto.sync.start()

Auth Handler: Swift 6 Actor Isolation Requirement

If your DittoManager (or whatever class holds the Ditto instance) is an actor, the expirationHandler closure is @Sendable and executes off the actor. Any access to actor-isolated state inside a Task { } block requires await:
// BROKEN β€” Swift 6 strict concurrency error
ditto.auth?.expirationHandler = { expiredDitto, timeUntilExpiration in
    if let error = someError {
        Task {
            self.myActorIsolatedProperty = error  // ← error: actor-isolated
        }
    }
}

// FIXED
ditto.auth?.expirationHandler = { expiredDitto, timeUntilExpiration in
    if let error = someError {
        Task {
            await self.myActorIsolatedProperty = error  // ← correct
        }
    }
}

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(
    databaseID: "your-database-id",
    connect: .server(url: URL(string: "https://your-server.ditto.live")!)
)
let ditto = try await Ditto.open(config: config)

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

// Now safe to start sync
try 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(
    databaseID: "your-database-id",
    connect: .server(url: URL(string: "https://your-server.ditto.live")!)
)
let ditto = try await Ditto.open(config: config)

// No need to set DQL_STRICT_MODE - false is now the default
try 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(
        identity: .onlinePlayground(
            appID: "your-app-id",
            token: "your-token",
            enableDittoCloudSync: false,
            customAuthURL: URL(string: "https://your-server.ditto.live")
        )
    )
    
    // Set DQL_STRICT_MODE to false for Query Builder compatibility
    try? ditto.store.execute(query: "ALTER SYSTEM SET DQL_STRICT_MODE = false")
    
    try? ditto.startSync()
    
  2. Convert all queries from Legacy Query Builder to DQL See the Swift 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-{databaseID} instead of ditto. This is for only new databases created in v5. v5 does not migrate existing databases to the new structure. Provide custom persistence directory:
let config = DittoConfig(
    databaseID: "your-database-id",
    connect: .server(url: URL(...)),
    persistenceDirectory: URL(fileURLWithPath: "/app/sandbox/ditto")  // custom path to save the database 
)

Observer Changes

v4 and v5 both use callback-based observers. The core registerObserver API pattern is the same in both versions, returning a DittoStoreObserver that you retain and call .stop() on for cleanup.
var observer: DittoStoreObserver?

observer = try ditto.store.registerObserver(
    query: "SELECT * FROM cars WHERE color = :color",
    arguments: ["color": "red"]
) { result in
    let cars = result.items.compactMap { item -> Car? in
        let car = Car(item.jsonData())
        item.dematerialize()  // Free native memory β€” do not use item after this
        return car
    }
    DispatchQueue.main.async { self.cars = cars }
}

// Cleanup
deinit {
    observer?.stop()
}
Key changes:
  • v5 adds Codable argument overloads for registerObserver
  • Call item.dematerialize() after extracting data from DittoQueryResultItem to free native memory
  • The observer lifecycle pattern (retain, .stop(), cleanup) is the same as v4

Back Pressure (Signal Next)

The standard registerObserver automatically signals readiness for the next callback after your handler returns. For handlers that need time to process results β€” such as expensive rendering or batch operations β€” use registerObserver(query:arguments:deliverOn:handlerWithSignalNext:) to control when the next callback is delivered.
Coalescing behavior: While your handler is processing, Ditto coalesces any intermediate changes. When you call signalNext(), you receive a single callback with the latest state β€” not every intermediate change.
// Standard observer β€” signalNext called automatically after handler returns
let observer = try ditto.store.registerObserver(
    query: "SELECT * FROM cars WHERE color = :color",
    arguments: ["color": "blue"]
) { result in
    updateUI(result.items)
    // signalNext() called automatically here
}

// Back-pressure observer β€” you control when the next callback fires
let observer = try ditto.store.registerObserver(
    query: "SELECT * FROM cars WHERE color = :color",
    arguments: ["color": "blue"],
    handlerWithSignalNext: { result, signalNext in
        Task {
            await expensiveRenderOperation(result.items)
            signalNext()  // Ready for next update
        }
    }
)
When to use signal next:
  • Processing updates is computationally expensive
  • You’re performing async work (network calls, heavy rendering) on each update
  • Query results change very frequently and you want to process only the latest state

DittoDiskUsageItem Property Names (v5, Unchanged)

DiskUsageItem was renamed to DittoDiskUsageItem, however, the property names are unchanged in v5:
  • .path: String β€” unchanged
  • .sizeInBytes: Int β€” unchanged
  • .childItems: [DittoDiskUsageItem] β€” unchanged (type renamed, property name same)

DittoConnection Property Renames

v4.14v5.0
connection.peerKeyString1connection.peer1
connection.peerKeyString2connection.peer2
connection.approximateDistanceInMetersREMOVED β€” no replacement

DittoPeer Property Renames (v5)

v4.14v5.0
peer.peerKeyStringpeer.peerKey
peer.osV2: DittoPeerOS?peer.os: DittoPeerOS?
peer.isConnectedToDittoCloudpeer.isConnectedToDittoServer

Breaking: DittoConnection Conformances Cause Swift 6 filter Ambiguity

In v5, DittoConnection gains Identifiable, Equatable, Hashable, and Sendable conformances. In Swift 6, Foundation.filter(_:Predicate) takes precedence over Sequence.filter(_:) when the element type conforms to these protocols, causing an unexpected compile error. Error: trailing closure passed to parameter of type 'Predicate<DittoConnection>' Fix: Annotate the closure parameter type explicitly:
// BROKEN in Swift 6
connections.filter { $0.peer1 == localKey }

// FIXED
connections.filter { (conn: DittoConnection) in conn.peer1 == localKey }

DittoLogger Changes

DittoLogger retains the same class name in both v4 and v5. The key changes are:
  • v5 marks DittoLogger as final and Sendable for Swift concurrency safety
  • DittoLogger.setLogFile() is removed in v5 (was deprecated in v4 β€” use export(to:) instead)

API Changes

v4.14v5.0
ditto.startSync()ditto.sync.start()
ditto.stopSync()ditto.sync.stop()
ditto.isSyncActiveditto.sync.isActive
ditto.observePeers(_:) / observePeersV2(_:)ditto.presence.observe(didChangeHandler:)
ditto.activatedditto.isActivated
peerKeyStringpeerKey
isConnectedToDittoCloudisConnectedToDittoServer
Ditto(identity:)Ditto.open(config:) (async) or Ditto.openSync(config:)
ditto.siteIDNot a standalone property β€” access via ditto.presence.graph.localPeer
ditto.appIDditto.config.databaseID
DiskUsage classDittoDiskUsage class
ditto.diskUsage (typed DiskUsage)ditto.diskUsage (now typed DittoDiskUsage)
DiskUsage.exec: DiskUsageItemDittoDiskUsage.item: DittoDiskUsageItem
DiskUsage.DiskUsageObserverHandle (nested)DittoDiskUsageObserver (standalone class)
DiskUsage.observe(eventHandler:) returns DiskUsageObserverHandleDittoDiskUsage.observe(eventHandler:) returns DittoDiskUsageObserver
DiskUsageItemDittoDiskUsageItem
DittoSwiftError typealiasRemoved β€” use DittoError directly
DittoError.migrationError(reason:)Removed
MigrationErrorReason enumRemoved
DittoSmallPeerInfo.syncScope: DittoSmallPeerInfoSyncScopeDQL: ALTER SYSTEM SET sync_scope = '...'
DittoSmallPeerInfoSyncScope enumRemoved β€” configure via DQL
DittoSmallPeerInfo.metadataJSONStringDittoSmallPeerInfo.metadataJSONData
DittoSmallPeerInfo.setMetadataJSONString(_:)DittoSmallPeerInfo.setMetadataJSONData(_:)
DittoLogger.setLogFile()Removed β€” use export(to:) instead

WebSocket Sync Default Changed

DittoHTTPListenConfig.webSocketSync default changed from true to false. If your app relied on the v4 default, you must now explicitly enable it.

Sync Subscription Changes

DittoSync.subscriptions is now computed rather than a stored Swift Set. Do not cache this collection β€” query it fresh when needed. The same applies to DittoStore.observers.

APIs Removed

These v4 APIs were removed
Removed APINotes
Ditto.transportDiagnostics() / DittoTransportDiagnosticsLow-level transport debug info removed
Ditto.runGarbageCollection()GC is now handled automatically
Ditto.disableSyncWithV3()v3 protocol support fully dropped
Ditto.isHistoryTrackingEnabled / history tracking initializerHistory tracking removed entirely
DittoIdentity.manual(certificateConfig:)Manual certificate identity removed
DittoConnection.approximateDistanceInMetersPeer distance estimation removed
DittoHTTPListenConfig.staticContentPathStatic HTTP content serving removed
DittoStore.queriesHash(queries:)Query set hashing removed
DittoStore.queriesHashMnemonic(queries:)Query mnemonic hashing removed
DittoConnectionPriority enumConnection priority control removed
DittoTransportSnapshot structRemoved with transport diagnostics
DittoRemotePeer struct (deprecated)Use DittoPeer in DittoPresenceGraph
DittoAddress structRemoved
DittoExperimental.jsonByTranscoding(cbor:)CBOR-to-JSON utility removed
DittoLogger.setLogFile()Use export(to:) instead

New APIs in v5

  • Ditto.open(config:): async throws -> Ditto β€” async factory method
  • Ditto.openSync(config:): throws -> Ditto β€” synchronous factory alternative
  • ditto.config: DittoConfig β€” read the config used to open this instance
  • ditto.isActivated: Bool β€” replaces ditto.activated
  • DittoStore.newAttachment(data: Data, metadata:) β€” create attachment from in-memory Data (v4 was file path only)
  • DittoStore.registerObserver(query:arguments: some Codable, ...) β€” overloads accepting Codable query arguments
  • DittoSync.registerSubscription(query:arguments: some Codable) β€” overload accepting Codable arguments
  • DittoDiskUsageObserver β€” standalone observer class (was nested DiskUsage.DiskUsageObserverHandle)
  • DittoDiskUsageItem β€” renamed from DiskUsageItem
  • DittoSyncSubscription.queryArgumentsCBORData / .queryArgumentsJSONData / .id β€” new properties for introspection
  • DittoConnection now conforms to Identifiable, Equatable, Hashable, and Sendable
  • All public types now final and Sendable for Swift concurrency
  • DittoConfig and DittoConfigConnect now conform to Sendable and Codable

Migration Checklist

Initialization

  • Replace Ditto(identity:) with Ditto.open(config:)
  • Create DittoConfig with databaseID and connect mode
  • Add try await for async initialization
  • Update .onlinePlayground β†’ .server(url:)
  • Update .offlinePlayground β†’ .smallPeersOnly(privateKey: nil)
  • Remove updateTransportConfig calls
  • Remove disableSyncWithV3() calls
  • Set DQL_STRICT_MODE=true BEFORE starting sync if maintaining v4 behavior
  • Update startSync() β†’ sync.start()

Authentication

  • Set expirationHandler closure after initialization
  • Use .development provider for playground tokens
  • Remove authentication from identity configuration

Observers

  • Call item.dematerialize() after extracting data from DittoQueryResultItem to free native memory
  • If processing is expensive, use handlerWithSignalNext: for back-pressure control
  • v5 adds Codable argument overloads β€” adopt where convenient

Data Operations

  • Replace all .collection("x").find(...) chains with store.execute() DQL
  • Use parameterized queries with :paramName β€” never string interpolation
  • Replace .upsert() with INSERT INTO ... ON ID CONFLICT DO UPDATE
  • Replace .update {} closures with UPDATE SET DQL
  • Replace .remove() with DELETE FROM
  • Replace .evict() with EVICT FROM
  • Replace .observeLocal {} with store.registerObserver() callback
  • Replace .subscribe() with sync.registerSubscription()
  • Replace counter?.increment(by:) with UPDATE APPLY ... PN_INCREMENT BY

Breaking Changes

  • Set persistenceDirectory if maintaining v4 directory structure
  • Update peerKeyString β†’ peerKey
  • Update diskUsage usage: DiskUsage β†’ DittoDiskUsage, .exec β†’ .item, DiskUsageObserverHandle β†’ DittoDiskUsageObserver
  • Update DittoConnection.peer1/peer2 comparisons: now String instead of Data
  • Update isConnectedToDittoCloud β†’ isConnectedToDittoServer
  • Update ditto.observePeers(_:) β†’ ditto.presence.observe(didChangeHandler:)
  • Update ditto.activated β†’ ditto.isActivated
  • Remove all DittoSwiftError references β€” use DittoError directly
  • Update DittoSmallPeerInfo.metadataJSONString β†’ metadataJSONData
  • Check for explicit webSocketSync = true if your app relied on v4 default
  • Update DittoLogger.setLogFile() calls β€” use export(to:) instead

Verification

  • Build compiles with zero errors
  • No deprecated API warnings
  • Observers update UI immediately
  • Authentication works before sync starts
  • No memory leaks on navigation
  • No memory leaks (Instruments: Allocations, 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. Forgetting try await: Ditto.open(config:) is async. Not awaiting it causes a compile error.
  3. Storing DittoQueryResultItem outside callback: These hold native memory. Always call dematerialize() and extract your model before the callback returns.
  4. String interpolation in queries: Never "SELECT * FROM cars WHERE color = '\(color)'". Always use arguments: ["color": color].
  5. Counter initialization: Don’t put DittoCounter() or 0 in insert documents. Counters are created on first PN_INCREMENT. Inserting 0 creates a REGISTER, not a COUNTER.
  6. Missing ON ID CONFLICT: INSERT fails if document _id already exists without a conflict clause.
  7. Not checking timeUntilExpiration in auth handler: Handler is called for both initial auth (0) and token refresh (>0). Handle both cases.