diff --git a/src/data/languages/languageData.ts b/src/data/languages/languageData.ts
index eee39146d0..2826da81ef 100644
--- a/src/data/languages/languageData.ts
+++ b/src/data/languages/languageData.ts
@@ -52,7 +52,7 @@ export default {
},
liveObjects: {
javascript: '2.21',
- swift: '0.4',
+ swift: '1.2',
java: '1.8',
},
liveSync: {
diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts
index aed0809e2e..ec37922c66 100644
--- a/src/data/nav/liveobjects.ts
+++ b/src/data/nav/liveobjects.ts
@@ -125,8 +125,9 @@ export default {
external: true,
},
{
- link: 'https://sdk.ably.com/builds/ably/ably-liveobjects-swift-plugin/main/AblyLiveObjects/documentation/ablyliveobjects/',
- name: 'Swift plugin',
+ // TODO: verify this URL resolves once the ably-cocoa-hosted AblyLiveObjects DocC is published
+ link: 'https://sdk.ably.com/builds/ably/ably-cocoa/main/AblyLiveObjects/documentation/ablyliveobjects/',
+ name: 'Swift SDK',
external: true,
},
{
diff --git a/src/pages/docs/api/realtime-sdk/channels.mdx b/src/pages/docs/api/realtime-sdk/channels.mdx
index 06c2978224..79bcea4487 100644
--- a/src/pages/docs/api/realtime-sdk/channels.mdx
+++ b/src/pages/docs/api/realtime-sdk/channels.mdx
@@ -103,18 +103,15 @@ Provides access to the [`RealtimeAnnotations`](#realtime-annotations) object for
-
+
#### object
A public field providing access to the [RealtimeObject](/docs/liveobjects) for this channel, which can be used to read, modify and subscribe to LiveObjects on a channel.
-
-
-#### objects
-
-Provides access to the [Objects](/docs/liveobjects) object for this channel which can be used to read, modify and subscribe to LiveObjects on a channel.
+An extension property provided by the AblyLiveObjects plugin, giving access to the [RealtimeObject](/docs/liveobjects) for this channel, which can be used to read, modify and subscribe to LiveObjects on a channel.
+
### Channel Methods
diff --git a/src/pages/docs/liveobjects/batch.mdx b/src/pages/docs/liveobjects/batch.mdx
index 4de143c36b..e0b0b72ee7 100644
--- a/src/pages/docs/liveobjects/batch.mdx
+++ b/src/pages/docs/liveobjects/batch.mdx
@@ -25,7 +25,7 @@ meta_description: "Group multiple objects operations into a single channel messa
diff --git a/src/pages/docs/liveobjects/concepts/instance.mdx b/src/pages/docs/liveobjects/concepts/instance.mdx
index 15727973b7..819f27d540 100644
--- a/src/pages/docs/liveobjects/concepts/instance.mdx
+++ b/src/pages/docs/liveobjects/concepts/instance.mdx
@@ -31,9 +31,7 @@ An `Instance` can also wrap a primitive value, for example when obtained from a
@@ -52,6 +50,18 @@ console.log(visits?.id); // e.g. counter:abc123@1234567890
console.log(visits?.value()); // e.g. 5
```
+```swift
+// Get a PathObject for the channel object
+let rootObject = try await channel.object.get()
+
+// Get the specific Instance of a LiveCounter located at the 'visits' key
+let visitsInstance = try rootObject.get(key: "visits").instance() // nil only if nothing exists at the path
+if case .liveCounter(let visits)? = visitsInstance {
+ print(visits.id) // e.g. counter:abc123@1234567890
+ print(try visits.value) // e.g. 5.0
+}
+```
+
```java
// Get a PathObject for the channel object
LiveMapPathObject rootObject = channel.object.get().join();
@@ -69,6 +79,9 @@ if (visitsInstance != null) {
The `instance()` method returns `undefined` only if nothing exists at that path. It wraps whatever value resolves there: a `LiveMap`, a `LiveCounter`, or a primitive value. A primitive-backed `Instance` is read-only: it has no `id` and exposes the primitive via `value()`.
+
+The `instance()` method returns `nil` only if nothing exists at that path. It wraps whatever value resolves there, surfaced as the corresponding case of the `Instance` enum: a `LiveMap` becomes `.liveMap`, a `LiveCounter` becomes `.liveCounter`, and a primitive value becomes `.primitive`. A primitive-backed `Instance` (`.primitive`) is read-only: it has no `id` and exposes the value through its `value` property.
+
The `instance()` method returns `null` only if nothing exists at that path. It wraps whatever value resolves there: a `LiveMap` or `LiveCounter` becomes a `LiveMapInstance` or `LiveCounterInstance`, and a primitive value becomes a read-only primitive instance (for example `StringInstance`).
@@ -84,6 +97,16 @@ const visits = rootObject.get('visits').instance();
await visits?.increment(5);
```
+```swift
+// Obtain an Instance for a LiveCounter
+let visitsInstance = try rootObject.get(key: "visits").instance()
+
+// Increment the specific LiveCounter instance
+if case .liveCounter(let visits)? = visitsInstance {
+ try await visits.increment(amount: 5)
+}
+```
+
```java
// Obtain an Instance for a LiveCounter
Instance visitsInstance = rootObject.get("visits").instance();
@@ -153,6 +176,51 @@ if (visitsInstance != null && visitsInstance.getType() == ValueType.LIVE_COUNTER
See the [Type inference documentation](/docs/liveobjects/typing?lang=java#type-inference) for the full contract, including how the path-layer casts differ.
+
+## Type inference
+
+`Instance` is an enum with three cases: `.liveMap`, `.liveCounter` and `.primitive`. There are no casts on an `Instance`; discriminate between the cases with an exhaustive `switch`, which the compiler checks. A type mismatch error (code 92007, which the path layer throws for wrong-typed writes) cannot occur from this discrimination. In exchange, reads on a typed instance never return `nil` for a type mismatch: `try map.size` and `try counter.value` are non-optional.
+
+
+```swift
+switch try rootObject.get(key: "visits").instance() {
+case .liveCounter(let visits):
+ try await visits.increment(amount: 1)
+case .liveMap(let map):
+ print(try map.size)
+case .primitive(let primitive):
+ print(try primitive.value)
+case .none:
+ print("nothing exists at this path")
+}
+```
+
+
+Use `if case` when you only need to handle one type:
+
+
+```swift
+if case .liveCounter(let visits)? = try rootObject.get(key: "visits").instance() {
+ try await visits.increment(amount: 1)
+}
+```
+
+
+When you only need to inspect the type, read the `type` property on the `Instance`, which is non-optional and runs in constant time:
+
+
+```swift
+if let instance = try rootObject.get(key: "visits").instance() {
+ print(instance.type) // e.g. liveCounter
+}
+```
+
+
+Reads on an instance throw an `ARTErrorInfo` only when LiveObjects cannot be accessed at all: the channel is in the `DETACHED` or `FAILED` state (error code 90001), or the channel is missing the `object_subscribe` mode (error code 40024). They never throw for a type mismatch or an absent entry.
+
+See the [Type inference documentation](/docs/liveobjects/typing?lang=swift#type-inference) for the full contract, including how the path-layer casts differ.
+
+
## Navigate an Instance
For `LiveMap` instances, use the `get(key)` method to navigate to a child value:
@@ -162,6 +230,11 @@ For `LiveMap` instances, use the `get(key)` method to navigate to a child value:
Unlike `PathObject`, the `get(key)` method on an `Instance` can return `undefined` if the entry doesn't exist, if the object at that location has been deleted, or if the current instance is not a `LiveMap`.
+
+
+
+
+In Swift, a wrong-typed call cannot happen: the method simply doesn't exist on the typed instance, and the `Instance` enum has no casts to get wrong. Match the case you need, or check the [`type`](/docs/liveobjects/typing?lang=swift#get-type) property when the type isn't known. See the [Type inference documentation](/docs/liveobjects/typing?lang=swift#type-inference) for the full contract:
+
+
+```swift
+if let settings = try rootObject.get(key: "settings").instance() { // a LiveMap
+ print(settings.type) // liveMap
+
+ // Maps expose entries()/keys()/values()/size and counters expose value -
+ // those members simply don't exist on the wrong type, so the compiler
+ // rejects a wrong-typed call before it can run:
+ if case .liveMap(let map) = settings {
+ print(try map.size)
+ }
+}
+```
+
+
+
### Enumerate collections
For `LiveMap` instances, you can iterate over the entries, keys, and values:
@@ -339,6 +476,23 @@ if (settings) {
}
```
+```swift
+if case .liveMap(let settings)? = try rootObject.get(key: "settings").instance() {
+
+ // Iterate over key-value pairs.
+ // Each key is a String, and the value is an Instance for the entry.
+ for (key, value) in try settings.entries() {
+ print("\(key): \(try value.compactJson())")
+ }
+
+ // Iterate over keys only
+ for key in try settings.keys() { print("Key: \(key)") }
+
+ // Iterate over values
+ for value in try settings.values() { print("Value: \(try value.compactJson())") }
+}
+```
+
```java
Instance settingsInstance = rootObject.get("settings").instance();
if (settingsInstance != null) {
@@ -387,6 +541,13 @@ const settings = rootObject.get('settings').instance();
console.log(settings?.size());
```
+```swift
+if case .liveMap(let settings)? = try rootObject.get(key: "settings").instance() {
+ // Get the number of entries - non-optional Int on an Instance
+ print(try settings.size)
+}
+```
+
```java
Instance settingsInstance = rootObject.get("settings").instance();
if (settingsInstance != null) {
@@ -413,6 +574,12 @@ console.log(visits?.size()); // undefined
The Java SDK exposes `compactJson()` as the supported snapshot of an instance; there is no `compact()` equivalent. On an `Instance` the result is non-null; cyclic references are broken via `{"objectId": …}` markers and binary data is base64-encoded.
+
+### Get a compact object
+
+The Swift SDK exposes `compactJson()` as the supported snapshot of an instance; there is no `compact()` equivalent. On an `Instance` the result is a non-optional `JSONValue`; cyclic references are broken via `{"objectId": …}` markers and binary data is base64-encoded.
+
+
### Get a compact object
@@ -470,7 +637,7 @@ It is possible for the value returned from `compact()` to contain cyclic referen
Use the `compactJson()` method to obtain a value that can be safely passed to `JSON.stringify()`:
-
+
Use the `compactJson()` method to obtain a JSON representation of the instance:
@@ -497,6 +664,20 @@ if (userInstance) {
}
```
+```swift
+// Example: Using the same cyclic structure from before
+// user (LiveMap)
+// ├─ profile -> references user (creates cycle)
+// └─ name: "Alice"
+
+if let userInstance = try rootObject.get(key: "user").instance() {
+ // compactJson() breaks cycles, making it safe to serialize.
+ // The cycle is broken via an objectId reference.
+ print(try userInstance.compactJson()) // non-optional on an Instance
+ // {"profile":{"objectId":"map:abc123@1234567890"},"name":"Alice"}
+}
+```
+
```java
// Example: Using the same cyclic structure from before
// user (LiveMap)
@@ -530,6 +711,20 @@ if (rootInstance) {
}
```
+```swift
+// The channel object always exists, so instance() resolves to a LiveMap here
+if case .liveMap(let rootInstance)? = try rootObject.instance() {
+
+ // Store binary data in the LiveMap
+ let binaryData = Data("world".utf8)
+ try await rootInstance.set(key: "hello", value: .primitive(.data(binaryData)))
+
+ // compactJson() converts binary data to base64 strings
+ let json = try rootInstance.compactJson()
+ print(json.objectValue?["hello"]?.stringValue ?? "") // "d29ybGQ="
+}
+```
+
```java
// The root LiveMap always exists, so instance() is non-null here
LiveMapInstance rootInstance = rootObject.instance().asLiveMap();
@@ -566,6 +761,20 @@ await visits?.increment(5);
await visits?.decrement(2);
```
+```swift
+// Update a LiveMap instance
+if case .liveMap(let settings)? = try rootObject.get(key: "settings").instance() {
+ try await settings.set(key: "theme", value: "dark")
+ try await settings.remove(key: "oldSetting")
+}
+
+// Update a LiveCounter instance
+if case .liveCounter(let visits)? = try rootObject.get(key: "visits").instance() {
+ try await visits.increment(amount: 5)
+ try await visits.decrement(amount: 2)
+}
+```
+
```java
// Update a LiveMap instance
Instance settingsInstance = rootObject.get("settings").instance();
@@ -628,6 +837,17 @@ if (visits) {
}
```
+```swift
+if case .liveCounter(let visits)? = try rootObject.get(key: "visits").instance() {
+ let subscription = try visits.subscribe { _ in
+ print("Visits updated")
+ }
+
+ // Later, stop listening to changes
+ subscription.unsubscribe()
+}
+```
+
```java
Instance visitsInstance = rootObject.get("visits").instance();
if (visitsInstance != null) {
@@ -660,11 +880,34 @@ if (visits) {
+
+Alternatively, use the `events()` method to consume updates as an [`AsyncStream`](https://developer.apple.com/documentation/swift/asyncstream) with `for await`. Breaking out of the loop, or cancelling the enclosing task, automatically unsubscribes:
+
+
+```swift
+if case .liveCounter(let visits)? = try rootObject.get(key: "visits").instance() {
+ for await _ in try visits.events() {
+ print("Visits updated")
+
+ if someCondition {
+ break // Unsubscribes
+ }
+ }
+}
+```
+
+
+
+
+
+
+
+- The `object` property contains the same object instance you called `subscribe()` on, as the `Instance` enum. Match it back with `if case`; the match always succeeds here because you already know the type you subscribed on.
+- The `message` property contains the [`ObjectMessage`](/docs/liveobjects/concepts/operations?lang=swift#properties) which details the operation that caused the change, including information about the client that performed the operation and the specific changes made. It is optional.
+
- `getObject()` returns the same object instance you called `subscribe()` on, as the base `Instance` type. Cast it back with the matching `as*` method; the cast is safe here because you already know the type you subscribed on.
- `getMessage()` returns the [`ObjectMessage`](/docs/liveobjects/concepts/operations#properties) which details the operation that caused the change, including information about the client that performed the operation and the specific changes made. It is nullable.
@@ -738,6 +1000,23 @@ if (visits) {
}
```
+```swift
+if case .liveCounter(let visits)? = try rootObject.get(key: "visits").instance() {
+ try visits.subscribe { event in
+ if case .liveCounter(let object) = event.object {
+ if let value = try? object.value {
+ print("New value: \(value)")
+ }
+ if let message = event.message {
+ print("Updated by: \(message.clientId ?? "")")
+ print("Operation: \(message.operation.action)") // e.g. counterInc
+ }
+ print(object.id == visits.id) // true - same instance
+ }
+ }
+}
+```
+
```java
Instance visitsInstance = rootObject.get("visits").instance();
if (visitsInstance != null) {
@@ -778,6 +1057,24 @@ if (settings) {
}
```
+```swift
+if case .liveMap(let settings)? = try rootObject.get(key: "settings").instance() {
+
+ // Subscribe to the settings LiveMap instance
+ try settings.subscribe { _ in
+ print("Settings LiveMap updated")
+ }
+
+ // This triggers the subscription (direct change to settings)
+ try await settings.set(key: "theme", value: "dark")
+
+ // This does NOT trigger the subscription (change to nested object)
+ if case .liveMap(let preferences)? = try settings.get(key: "preferences") {
+ try await preferences.set(key: "language", value: "en")
+ }
+}
+```
+
```java
Instance settingsInstance = rootObject.get("settings").instance();
if (settingsInstance != null) {
diff --git a/src/pages/docs/liveobjects/concepts/objects.mdx b/src/pages/docs/liveobjects/concepts/objects.mdx
index 5ffb193c3c..aa4dcd810a 100644
--- a/src/pages/docs/liveobjects/concepts/objects.mdx
+++ b/src/pages/docs/liveobjects/concepts/objects.mdx
@@ -87,12 +87,20 @@ await settings.set('fontSize', 14);
```
```swift
-// Create a LiveMap
-let userSettings = try await channel.objects.createMap()
+// rootObject obtained from channel.object.get()
+
+// Create a LiveMap with initial entries
+let initialSettings = LiveMap.create(entries: [
+ "theme": "dark",
+ "notifications": true,
+])
+
+// Assign it to the 'settings' key on the channel object
+try await rootObject.set(key: "settings", value: .liveMap(initialSettings))
-// Set primitive values
-try await userSettings.set(key: "theme", value: "dark")
-try await userSettings.set(key: "notifications", value: true)
+// Access and update it through its path
+let settings = rootObject.get(key: "settings").asLiveMap()
+try await settings.set(key: "fontSize", value: 14)
```
```java
@@ -130,11 +138,15 @@ await visits.increment(1);
```
```swift
-// Create a LiveCounter
-let visitsCounter = try await channel.objects.createCounter();
+// Create a LiveCounter with initial value 0
+let visitsCounter = LiveCounter.create(initialCount: 0)
+
+// Assign it to the 'visits' key on the channel object
+try await rootObject.set(key: "visits", value: .liveCounter(visitsCounter))
-// Increment the counter
-try await visitsCounter.increment(amount: 1);
+// Access and update it through its path
+let visits = rootObject.get(key: "visits").asLiveCounter()
+try await visits.increment(amount: 1)
```
```java
@@ -150,7 +162,7 @@ visits.increment(1).join();
```
-
+
## Channel object
The channel object is a special `LiveMap` instance which:
@@ -163,6 +175,9 @@ The channel object is a special `LiveMap` instance which:
Access the channel object using `channel.object.get()`, which returns a [`PathObject`](/docs/liveobjects/concepts/path-object) that resolves to the root `LiveMap`:
+
+Access the channel object using `channel.object.get()`, which returns a [`LiveMapPathObject`](/docs/liveobjects/concepts/path-object?lang=swift) resolving to the root `LiveMap`. The `channel.object` accessor is available once you register the LiveObjects plugin through `clientOptions.plugins`:
+
Access the channel object using `channel.object.get()`, which returns a future that completes with a [`LiveMapPathObject`](/docs/liveobjects/concepts/path-object?lang=java) resolving to the root `LiveMap`:
@@ -181,6 +196,22 @@ await rootObject.set('config', LiveMap.create({
}));
```
+```swift
+// Implicitly attaches the channel; suspends until the state is synchronized
+let rootObject = try await channel.object.get()
+
+// Use it like any other LiveMap
+try await rootObject.set(key: "app-version", value: "1.0.0")
+
+// Create a config map
+let config = LiveMap.create(entries: [
+ "apiUrl": "https://api.example.com",
+])
+
+// Assign it to the 'config' key on the channel object
+try await rootObject.set(key: "config", value: .liveMap(config))
+```
+
```java
// Implicitly attaches the channel; the future completes once state is SYNCED
LiveMapPathObject rootObject = channel.object.get().join();
@@ -218,6 +249,18 @@ await rootObject.get('visits').increment(5);
await rootObject.get('settings').set('notifications', true);
```
+```swift
+// Get the channel object
+let rootObject = try await channel.object.get()
+
+// Purely navigational - nothing is resolved yet
+let visits = rootObject.get(key: "visits")
+
+// Resolution happens when a terminal method is called
+try await visits.asLiveCounter().increment(amount: 5)
+try await rootObject.get(key: "settings").asLiveMap().set(key: "notifications", value: true)
+```
+
```java
// Get the channel object
LiveMapPathObject rootObject = channel.object.get().join();
@@ -255,6 +298,24 @@ await rootObject.get('visits').instance()?.increment(5);
await rootObject.get('settings').instance()?.set('notifications', true);
```
+```swift
+// Get the channel object
+let rootObject = try await channel.object.get()
+
+// Navigate to nested paths and get the instance
+let themeInstance = try rootObject.get(key: "settings").asLiveMap().get(key: "theme").instance()
+
+// Work directly with the instance.
+// instance() returns nil only if nothing exists at the path
+if case .liveCounter(let visitsInstance)? = try rootObject.get(key: "visits").instance() {
+ try await visitsInstance.increment(amount: 5)
+}
+
+if case .liveMap(let settingsInstance)? = try rootObject.get(key: "settings").instance() {
+ try await settingsInstance.set(key: "notifications", value: true)
+}
+```
+
```java
// Get the channel object
LiveMapPathObject rootObject = channel.object.get().join();
@@ -283,30 +344,6 @@ See the [Instance documentation](/docs/liveobjects/concepts/instance) for detail
-
-### Root object
-
-The root object is a special `LiveMap` instance which:
-
-* Implicitly exists on a channel and does not need to be created explicitly
-* Has the special [objectId](#object-ids) of `root`
-* Cannot be deleted
-* Serves as the [entry point](#reachability) for accessing all other objects on a channel
-
-Access the root object using the `getRoot()` function:
-
-
-```swift
-// Get the Root Object
-let root = try await channel.objects.getRoot()
-
-// Use it like any other LiveMap
-try await root.set(key: "app-version", value: "1.0.0")
-```
-
-
-
-
## Composability
LiveObjects enables you to build complex, hierarchical data structures through composability.
@@ -340,23 +377,38 @@ await rootObject.get('profile').get('preferences').set('theme', 'light');
```
```swift
-// Create LiveObjects
-let profileMap = try await channel.objects.createMap()
-let preferencesMap = try await channel.objects.createMap()
-let activityCounter = try await channel.objects.createCounter()
+// Create a map for the nested preferences
+let preferences = LiveMap.create(entries: [
+ "theme": "dark",
+ "fontSize": 14,
+])
-// Build a composite structure
-try await preferencesMap.set(key: "theme", value: "dark")
-try await profileMap.set(key: "preferences", value: .liveMap(preferencesMap))
-try await profileMap.set(key: "activity", value: .liveCounter(activityCounter))
-try await root.set(key: "profile", value: .liveMap(profileMap))
+// Create a counter for the nested activity
+let activity = LiveCounter.create(initialCount: 0)
+
+// Compose the profile entries, referencing the nested objects
+let profile = LiveMap.create(entries: [
+ "name": "Alice",
+ "preferences": .liveMap(preferences),
+ "activity": .liveCounter(activity),
+])
+
+// Assign the profile map to the channel object.
+// The whole tree publishes as a single operation.
+try await rootObject.set(key: "profile", value: .liveMap(profile))
// Resulting structure:
-// root (LiveMap)
+// rootObject (LiveMap - channel object)
// └── profile (LiveMap)
+// ├── name: "Alice" (string)
// ├── preferences (LiveMap)
-// │ └── theme: "dark" (string)
+// │ ├── theme: "dark" (string)
+// │ └── fontSize: 14 (number)
// └── activity (LiveCounter)
+
+// Update nested values through their paths
+try await rootObject.at(path: "profile.activity").asLiveCounter().increment(amount: 5)
+try await rootObject.at(path: "profile.preferences").asLiveMap().set(key: "theme", value: "light")
```
```java
@@ -397,79 +449,14 @@ rootObject.at("profile.preferences").asLiveMap().set("theme", LiveMapValue.of("l
-
-It is possible for the same object instance to be accessed from multiple places in your object tree:
-
-
-```swift
-// Create a counter
-let counter = try await channel.objects.createCounter()
-
-// Create two different maps
-let mapA = try await channel.objects.createMap()
-let mapB = try await channel.objects.createMap()
-try await root.set(key: "a", value: .liveMap(mapA))
-try await root.set(key: "b", value: .liveMap(mapB))
-
-// Reference the same counter from both maps
-try await mapA.set(key: "count", value: .liveCounter(counter))
-try await mapB.set(key: "count", value: .liveCounter(counter))
-
-// The counter referenced from each location shows the same
-// value, since they refer to the same underlying counter
-try mapA.get(key: "count")?.liveCounterValue?.subscribe { _, _ in
- do {
- let value = try mapA.get(key: "count")?.liveCounterValue?.value
- print(String(describing: value)) // 1
- } catch {
- // Error not relevant here
- }
-}
-try mapB.get(key: "count")?.liveCounterValue?.subscribe { _, _ in
- do {
- let value = try mapB.get(key: "count")?.liveCounterValue?.value
- print(String(describing: value)) // 1
- } catch {
- // Error not relevant here
- }
-}
-
-// Increment the counter
-try await counter.increment(amount: 1)
-```
-
-
-It is also possible that object references form a cycle:
-
-
-```swift
-// Create two different maps
-let mapA = try await channel.objects.createMap()
-let mapB = try await channel.objects.createMap()
-
-// Set up a circular reference
-try await mapA.set(key: "ref", value: .liveMap(mapB))
-try await mapB.set(key: "ref", value: .liveMap(mapA))
-
-// Add one map to root (both are now reachable)
-try await root.set(key: "a", value: .liveMap(mapA))
-
-// We can traverse the cycle
-_ = try root.get(key: "a")? // mapA
- .liveMapValue?.get(key: "ref")? // mapB
- .liveMapValue?.get(key: "ref") // mapA
-```
-
-
-
## Reachability
All objects must be reachable from the channel object (directly or indirectly). Objects that cannot be reached from the channel object will eventually [be deleted](/docs/liveobjects/lifecycle#objects-deleted).
-
+
When you replace or remove an object reference, it may become unreachable and will eventually be [deleted](/docs/liveobjects/lifecycle#objects-deleted):
@@ -493,6 +480,26 @@ if (oldCounter) {
}
```
+```swift
+// Create and assign a counter
+let visitsCounter = LiveCounter.create(initialCount: 0)
+try await rootObject.set(key: "visits", value: .liveCounter(visitsCounter))
+
+// Get the counter instance to track it
+if case .liveCounter(let oldCounter)? = try rootObject.get(key: "visits").instance() {
+ try oldCounter.subscribe { event in
+ if event.message?.operation.action == .objectDelete {
+ print("Old counter was deleted")
+ }
+ }
+}
+
+// Replace with a new counter - the old instance becomes unreachable
+let replacementCounter = LiveCounter.create(initialCount: 0)
+try await rootObject.set(key: "visits", value: .liveCounter(replacementCounter))
+// The subscription will fire with the delete notification
+```
+
```java
// Create and assign a counter
LiveCounter visitsCounter = LiveCounter.create(0);
@@ -517,27 +524,6 @@ rootObject.set("visits", LiveMapValue.of(replacementCounter)).join();
-
-In the example below, the only reference to the `counterOld` object is replaced on the `root`. This makes `counterOld` unreachable and it will eventually be [deleted](/docs/liveobjects/lifecycle#objects-deleted).
-
-
-```swift
-// Create a counter and reference it from the root
-let counterOld = try await channel.objects.createCounter()
-try await root.set(key: "myCounter", value: .liveCounter(counterOld))
-
-// counterOld will eventually be deleted
-counterOld.on(event: .deleted) { _ in
- print("counterOld has been deleted and can no longer be used")
-}
-
-// Create a new counter and replace the old one referenced from the root
-let counterNew = try await channel.objects.createCounter()
-try await root.set(key: "myCounter", value: .liveCounter(counterNew))
-```
-
-
-
@@ -554,7 +540,7 @@ When using a client library, metadata is handled internally. However, this infor
Every object has a unique identifier that distinguishes it from all other objects.
-
+
You can access an object's ID using the [Instance](/docs/liveobjects/concepts/instance) API:
@@ -568,6 +554,14 @@ if (counterInstance) {
}
```
+```swift
+if case .liveCounter(let counterInstance)? = try rootObject.get(key: "visits").instance() {
+ let objectId = counterInstance.id
+ print("Object ID: \(objectId)")
+ // e.g., "counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000"
+}
+```
+
```java
Instance counterInstance = rootObject.get("visits").instance();
@@ -586,15 +580,8 @@ Object IDs are opaque strings that uniquely identify each object instance. The c
Tombstones are markers indicating an object or map entry has been deleted.
-
* A tombstone is created for an object when it becomes [unreachable](#reachability) from the channel object.
* A tombstone is created for a map entry when it is [removed](/docs/liveobjects/map#remove)
-
-
-
-* A tombstone is created for an object when it becomes [unreachable](#reachability) from the root object.
-* A tombstone is created for a map entry when it is [removed](/docs/liveobjects/map#remove)
-
Tombstones protect against lagging clients from re-introducing a deleted value, ensuring all clients eventually converge on the same state. They are eventually garbage collected after a safe period of time.
diff --git a/src/pages/docs/liveobjects/concepts/operations.mdx b/src/pages/docs/liveobjects/concepts/operations.mdx
index 45a143e336..c57efbea88 100644
--- a/src/pages/docs/liveobjects/concepts/operations.mdx
+++ b/src/pages/docs/liveobjects/concepts/operations.mdx
@@ -55,11 +55,9 @@ await rootObject.get('user').remove('status');
```
```swift
-// Set a value for a key
-try await map.set(key: "username", value: "alice")
-
-// Remove a key
-try await map.remove(key: "username")
+// rootObject obtained from channel.object.get()
+try await rootObject.get(key: "user").asLiveMap().set(key: "username", value: "alice")
+try await rootObject.get(key: "user").asLiveMap().remove(key: "status")
```
```java
@@ -89,11 +87,8 @@ await rootObject.get('score').decrement(2);
```
```swift
-// Increment counter by 5
-try await counter.increment(amount: 5)
-
-// Decrement counter by 2
-try await counter.decrement(amount: 2)
+try await rootObject.get(key: "visits").asLiveCounter().increment(amount: 5)
+try await rootObject.get(key: "score").asLiveCounter().decrement(amount: 2)
```
```java
@@ -106,7 +101,7 @@ rootObject.get("score").asLiveCounter().decrement(2).join();
Create operations are used to instantiate new objects of a given type.
-
+
Use `LiveMap.create()` and `LiveCounter.create()` to create new objects. These methods create special value types that can be assigned directly to paths:
@@ -131,6 +126,42 @@ await rootObject.set('profile', LiveMap.create({
}));
```
+```swift
+// Create a map with initial values
+let user = LiveMap.create(entries: [
+ "username": "alice",
+ "status": "online",
+])
+
+// Assign it to the 'user' key on the channel object
+try await rootObject.set(key: "user", value: .liveMap(user))
+
+// Create a counter with initial value
+let scoreCounter = LiveCounter.create(initialCount: 100)
+
+// Assign it to the 'score' key on the channel object
+try await rootObject.set(key: "score", value: .liveCounter(scoreCounter))
+
+// Create a map for the nested settings
+let settings = LiveMap.create(entries: [
+ "theme": "dark",
+ "notifications": true,
+])
+
+// Create a counter for the nested score
+let profileScore = LiveCounter.create(initialCount: 0)
+
+// Compose the profile entries, referencing the nested objects
+let profile = LiveMap.create(entries: [
+ "name": "Alice",
+ "score": .liveCounter(profileScore),
+ "settings": .liveMap(settings),
+])
+
+// Assign the profile map to the channel object
+try await rootObject.set(key: "profile", value: .liveMap(profile))
+```
+
```java
// Create a map with initial values
LiveMap user = LiveMap.create(Map.of(
@@ -175,34 +206,11 @@ Objects created with `LiveMap.create()` and `LiveCounter.create()` are automatic
-
-A create operation can optionally specify an initial value for the object.
-
-
-```swift
-// Create a map with initial values
-let userMap = try await channel.objects.createMap(entries: [
- "username": "alice",
- "status": "online",
-])
-
-// Create a counter with initial value
-let scoreCounter = try await channel.objects.createCounter(count: 100)
-```
-
-
-When a create operation is processed, an [object ID](/docs/liveobjects/concepts/objects#object-ids) for the new object instance is automatically generated for the object.
-
-
-
-
## Object IDs
Every operation is expressed relative to a specific object instance, identified by its [object ID](/docs/liveobjects/concepts/objects#object-ids), which determines which object the operation is applied to.
-
+
When using a `PathObject`, the specific object instance at the given path is evaluated at the time a method is called that updates the object. The client library will then publish an operation targeting the resolved object.
@@ -222,8 +230,14 @@ if (userInstance) {
```
```swift
-// The published operation targets the object ID of the `userMap` object instance
-try await userMap.set(key: "username", "alice")
+// The operation targets the object ID of whatever is at 'user' right now
+try await rootObject.get(key: "user").asLiveMap().set(key: "username", value: "alice")
+
+// Inspect the specific instance and its ID
+if case .liveMap(let userMap)? = try rootObject.get(key: "user").instance() {
+ print("Object ID: \(userMap.id)")
+ try await userMap.set(key: "username", value: "alice")
+}
```
```java
@@ -240,49 +254,7 @@ if (userInstance != null) {
```
-
-Therefore it is important that you obtain an up-to-date object instance before performing operations on an object. For example, you can [subscribe](/docs/liveobjects/map#subscribe-data) to a `LiveMap` instance to ensure you always have an up-to-date reference to any child objects in the map:
-
-
-{ /* We can't map the JS example directly because Swift concurrency prevents us from mutating local variables in the way that the JS example does, so I tried to show how we might need to handle this scenario in a real-world app where things are isolated to the main actor. But it's long and ugly. */ }
-```swift
-struct MyView: View {
- var root: any LiveMap
- @State private var myCounter: (any LiveCounter)?
-
- var body: some View {
- Button("Increment the counter") {
- Task {
- try await myCounter?.increment(amount: 1)
- }
- }.onAppear {
- do {
- myCounter = try root.get(key: "myCounter")?.liveCounterValue
-
- // We keep a reference to the latest value that the root map
- // stores at the "myCounter" key, to ensure that upon tapping
- // the button, we increment the correct counter.
-
- try root.subscribe { _, _ in
- MainActor.assumeIsolated {
- do {
- myCounter = try root.get(key: "myCounter")?.liveCounterValue
- } catch {
- // Error handling of root.get(key:) omitted for brevity
- }
- }
- }
- } catch {
- // Error handling of root.get(key:) omitted for brevity
- }
- }
- }
-}
-```
-
-
-
-In the [REST API](/docs/liveobjects/rest-api-usage#updating-objects-by-id), the relationship between operations and object IDs is made explicit:
+In the [REST API](/docs/liveobjects/rest-api-usage#update-by-id), the relationship between operations and object IDs is made explicit:
```shell
@@ -311,7 +283,7 @@ curl -X POST https://main.realtime.ably.net/channels/my-channel/objects \
-
+
## Object message properties
@@ -344,9 +316,9 @@ The `operation` field of an `ObjectMessage` contains an `ObjectOperation` with t
| Property | Description |
|----------|-------------|
-| **action** | The operation action. One of: `'map.create'`, `'map.set'`, `'map.remove'`, `'counter.create'`, `'counter.inc'`, or `'object.delete'`An `ObjectOperationAction` enum value: `MAP_CREATE`, `MAP_SET`, `MAP_REMOVE`, `MAP_CLEAR`, `COUNTER_CREATE`, `COUNTER_INC`, `OBJECT_DELETE`, or `UNKNOWN` (future-compatibility fallback) |
+| **action** | The operation action. One of: `'map.create'`, `'map.set'`, `'map.remove'`, `'counter.create'`, `'counter.inc'`, or `'object.delete'`An `ObjectOperationAction` enum value: `mapCreate`, `mapSet`, `mapRemove`, `counterCreate`, `counterInc`, `objectDelete`, or `mapClear`An `ObjectOperationAction` enum value: `MAP_CREATE`, `MAP_SET`, `MAP_REMOVE`, `MAP_CLEAR`, `COUNTER_CREATE`, `COUNTER_INC`, `OBJECT_DELETE`, or `UNKNOWN` (future-compatibility fallback) |
| **objectId** | The ID of the object the operation was applied to |
-| **mapCreate** | Present for `'map.create'``MAP_CREATE` operations. Defines the initial value of the map object with `semantics` (conflict-resolution strategy, one of: `'lww'`an `ObjectsMapSemantics` enum value: `LWW`, or `UNKNOWN` as a future-compatibility fallback) and `entries` (initial key-value pairs, where each value is an [`ObjectsMapEntry`](#objects-map-entry)) |
+| **mapCreate** | Present for `'map.create'``mapCreate``MAP_CREATE` operations. Defines the initial value of the map object with `semantics` (conflict-resolution strategy, one of: `'lww'`an `ObjectsMapSemantics` enum value: `lww`an `ObjectsMapSemantics` enum value: `LWW`, or `UNKNOWN` as a future-compatibility fallback) and `entries` (initial key-value pairs, where each value is an [`ObjectsMapEntry`](#objects-map-entry)) |
| **mapSet** | Present for `'map.set'` operations. Contains `key` (the key that was set) and `value` (an [`ObjectData`](#object-data) representing the value assigned to the key) |
| **mapRemove** | Present for `'map.remove'` operations. Contains `key` (the key that was removed) |
| **counterCreate** | Present for `'counter.create'` operations. Defines the initial value of the counter object with `count` (initial counter value) |
@@ -372,7 +344,7 @@ An `ObjectData` object represents a value assigned to a map key. It is used as t
|----------|-------------|
| **objectId** | A reference to another object (such as a `LiveMap` or `LiveCounter`) by its object ID |
| **boolean** | A boolean leaf value |
-| **bytes** | A binary leaf value (`ArrayBuffer` in browser environments, or `Buffer` in Node.js`byte[]`) |
+| **bytes** | A binary leaf value (`ArrayBuffer` in browser environments, or `Buffer` in Node.js`Data``byte[]`) |
| **number** | A numeric leaf value |
| **string** | A string leaf value |
| **json** | A parsed JSON object or array leaf value |
diff --git a/src/pages/docs/liveobjects/concepts/path-object.mdx b/src/pages/docs/liveobjects/concepts/path-object.mdx
index e365f0af64..6308c7a5c4 100644
--- a/src/pages/docs/liveobjects/concepts/path-object.mdx
+++ b/src/pages/docs/liveobjects/concepts/path-object.mdx
@@ -29,9 +29,7 @@ A `PathObject` represents a path to a specific location within the channel objec
@@ -48,6 +46,14 @@ const rootObject = await channel.object.get();
console.log(rootObject.path()); // ""
```
+```swift
+// Get a PathObject for the channel object
+let rootObject = try await channel.object.get()
+
+// This PathObject has an empty path
+print(rootObject.path) // ""
+```
+
```java
// Get a PathObject for the channel object
LiveMapPathObject rootObject = channel.object.get().join();
@@ -57,6 +63,9 @@ System.out.println(rootObject.path()); // ""
```
+
+In the Swift SDK the root is already typed as a `LiveMapPathObject`; no cast is needed at the root.
+
In the Java SDK the root is already typed as a `LiveMapPathObject`; no cast is needed at the root.
@@ -64,6 +73,9 @@ In the Java SDK the root is already typed as a `LiveMapPathObject`; no cast is n
Calling `channel.object.get()` implicitly [attaches](/docs/channels/states?lang=javascript#attach) to the channel if not already attached. The returned promise resolves when the channel object data has been [synchronized](/docs/liveobjects/concepts/synchronization) to the client.
+
+Calling `channel.object.get()` implicitly [attaches](/docs/channels/states?lang=swift#attach) to the channel if not already attached. The awaited call returns when the channel object data has been [synchronized](/docs/liveobjects/concepts/synchronization?lang=swift) to the client.
+
Calling `channel.object.get()` implicitly [attaches](/docs/channels/states?lang=java#attach) to the channel if not already attached. The returned `CompletableFuture` completes when the channel object data has been [synchronized](/docs/liveobjects/concepts/synchronization?lang=java) to the client.
@@ -86,6 +98,22 @@ await rootObject.set('visits', LiveCounter.create(0));
await visits.increment(1);
```
+```swift
+// Obtain a PathObject at the 'visits' key
+let visits = rootObject.get(key: "visits")
+
+// Increment the LiveCounter stored at this path
+try await visits.asLiveCounter().increment(amount: 5)
+
+// Someone replaces the LiveCounter instance stored at 'visits'
+let replacement = LiveCounter.create(initialCount: 0)
+try await rootObject.set(key: "visits", value: .liveCounter(replacement))
+
+// The same PathObject can be used to increment the LiveCounter instance
+// stored at the 'visits' key at the time the method is called
+try await visits.asLiveCounter().increment(amount: 1)
+```
+
```java
// Obtain a PathObject at the 'visits' key
PathObject visits = rootObject.get("visits");
@@ -137,6 +165,36 @@ const value = theme.value(); // Returns string | undefined
See the [Typing documentation](/docs/liveobjects/typing) for more details on type safety.
+
+## Type inference
+
+Swift has no user-supplied type parameters. Instead, infer the type of a path by calling one of the `as*` methods: `asLiveMap()`, `asLiveCounter()` or `asPrimitive()`. These casts never throw, even when the value at the path has a different type:
+
+
+```swift
+// Infer the 'visits' path as a LiveCounter and update through it
+let visits = rootObject.get(key: "visits").asLiveCounter()
+
+try await visits.increment(amount: 1)
+
+// A read through the wrong inferred type returns nil instead of throwing
+let theme = try rootObject.at(path: "settings.theme").asPrimitive().value()?.stringValue // String or nil
+
+// Check the type first when it isn't known
+if try rootObject.get(key: "score").type() == .liveCounter {
+ // safe to treat as a counter
+ try await rootObject.get(key: "score").asLiveCounter().increment(amount: 1)
+}
+```
+
+
+A wrong cast only shows up when you use it: reads return `nil` or an empty result and never throw for a type mismatch or an absent path, while writes throw an `ARTErrorInfo` (code `92007` on a type mismatch, `92005` when the path doesn't resolve) from a local check inside the awaited call, before any operation is sent. Reads are marked `try` only because they throw when the channel is in the `DETACHED` or `FAILED` state (error code `90001`), or when the channel is missing the `object_subscribe` mode (error code `40024`).
+
+Use `type()` to check what is stored at a path before inferring its type; it returns a `ValueType`, or `nil` when nothing resolves at the path. `exists()` reports whether anything resolves at the path at all. There is a single cast for primitive values: `asPrimitive()`, whose `value()` returns a `Primitive` enum that you pattern-match to get the concrete value, or read through convenience getters such as `stringValue` and `numberValue`.
+
+See the [Type inference documentation](/docs/liveobjects/typing?lang=swift#type-inference) for the full contract, including how the instance layer differs.
+
+
## Type inference
@@ -177,6 +235,11 @@ Use the `get(key)` method to navigate to a child path. The returned `PathObject`
Unlike `Instance`, the `get()` method on a `PathObject` never returns `undefined` because it references a location, not the actual value at that location.
+
+
+
+
+
+
+
+
+
+
+Alternatively, every `PathObject` exposes an `events()` method returning an [`AsyncStream`](https://developer.apple.com/documentation/swift/asyncstream) that you can consume with `for await`. Breaking out of the loop, or cancelling the enclosing task, automatically unsubscribes. See the [quickstart](/docs/liveobjects/quickstart/swift#step-6) for an example.
+
+
The Java SDK has no `subscribeIterator()` equivalent; a listener plus the returned `Subscription` is the only subscription form.
@@ -792,6 +1072,23 @@ await rootObject.set('visits', LiveCounter.create(100));
await visits.increment(1);
```
+```swift
+let visits = rootObject.get(key: "visits")
+
+// Subscribe to the 'visits' path
+try visits.subscribe { _ in print("Visits updated") }
+
+// This triggers the subscription
+try await visits.asLiveCounter().increment(amount: 5)
+
+// Someone replaces the LiveCounter instance at 'visits'
+let newCounter = LiveCounter.create(initialCount: 100)
+try await rootObject.set(key: "visits", value: .liveCounter(newCounter))
+
+// This triggers the subscription, which now observes the new LiveCounter
+try await visits.asLiveCounter().increment(amount: 1)
+```
+
```java
PathObject visits = rootObject.get("visits");
@@ -823,6 +1120,17 @@ theme.subscribe(() => {
await rootObject.get("settings").set("theme", "dark");
```
+```swift
+let theme = rootObject.get(key: "settings").asLiveMap().get(key: "theme")
+
+try theme.subscribe { _ in
+ let value = try? theme.asPrimitive().value()
+ print("Theme updated: \(value?.stringValue ?? "")")
+}
+
+try await rootObject.get(key: "settings").asLiveMap().set(key: "theme", value: "dark")
+```
+
```java
PathObject theme = rootObject.get("settings").asLiveMap().get("theme");
@@ -842,6 +1150,10 @@ The subscription receives an argument with information about the update:
- The `object` field contains a `PathObject` representing the location of the object instance that was updated.
- The `message` field contains the [`ObjectMessage`](/docs/liveobjects/concepts/operations#properties) which details the operation that caused the change, including information about the client that performed the operation and the specific changes made.
+
+- The `object` property contains a `PathObject` pointing to the path where the change occurred.
+- The `message` property contains the [`ObjectMessage`](/docs/liveobjects/concepts/operations#properties) which details the operation that caused the change, including information about the client that performed the operation and the specific changes made. It is nullable.
+
- `getObject()` returns a `PathObject` pointing to the path where the change occurred.
- `getMessage()` returns the [`ObjectMessage`](/docs/liveobjects/concepts/operations#properties) which details the operation that caused the change, including information about the client that performed the operation and the specific changes made. It is nullable.
@@ -858,6 +1170,19 @@ visits.subscribe(({ object, message }) => {
});
```
+```swift
+let visits = rootObject.get(key: "visits")
+
+try visits.subscribe { event in
+ let value = try? event.object.asLiveCounter().value()
+ print("New value: \(value ?? 0)")
+ if let message = event.message {
+ print("Updated by: \(message.clientId ?? "")")
+ print("Operation: \(message.operation.action)") // e.g. counterInc
+ }
+}
+```
+
```java
PathObject visits = rootObject.get("visits");
@@ -885,6 +1210,16 @@ visits.subscribe(({ object }) => {
await visits.increment(1);
```
+```swift
+let visits = rootObject.get(key: "visits")
+
+try visits.subscribe { event in
+ print(event.object.path) // always "visits"
+}
+
+try await visits.asLiveCounter().increment(amount: 1)
+```
+
```java
PathObject visits = rootObject.get("visits");
@@ -926,6 +1261,42 @@ await settings.set('theme', 'dark');
// path: settings.theme key: theme
```
+```swift
+// Subscribe to a LiveCounter stored in 'visits'
+let visits = rootObject.get(key: "visits")
+try visits.subscribe { event in
+ // counterInc is non-nil only for counter increment operations
+ if let counterInc = event.message?.operation.counterInc {
+ print("path: \(event.object.path) number: \(counterInc.number)")
+ }
+}
+try await visits.asLiveCounter().increment(amount: 5)
+// path: visits number: 5.0
+
+// Subscribe to a LiveMap stored in 'settings'
+let settings = rootObject.get(key: "settings").asLiveMap()
+try settings.subscribe { event in
+ // mapSet is non-nil only for map set operations
+ if let mapSet = event.message?.operation.mapSet {
+ print("path: \(event.object.path) key: \(mapSet.key)")
+ }
+}
+try await settings.set(key: "theme", value: "dark")
+// path: settings key: theme
+try await settings.get(key: "preferences").asLiveMap().set(key: "language", value: "en")
+// path: settings.preferences key: language
+
+// Subscribe to the 'theme' key in the LiveMap stored in 'settings'
+let theme = settings.get(key: "theme")
+try theme.subscribe { event in
+ if let mapSet = event.message?.operation.mapSet {
+ print("path: \(event.object.path) key: \(mapSet.key)")
+ }
+}
+try await settings.set(key: "theme", value: "dark")
+// path: settings.theme key: theme
+```
+
```java
// Subscribe to a LiveCounter stored in 'visits'
PathObject visits = rootObject.get("visits");
@@ -987,6 +1358,19 @@ await settings.set('theme', 'dark');
await settings.get('preferences').set('language', 'en');
```
+```swift
+let settings = rootObject.get(key: "settings").asLiveMap()
+
+try settings.subscribe { _ in
+ let theme = try? settings.get(key: "theme").asPrimitive().value()
+ print("Theme: \(theme?.stringValue ?? "")")
+ print("Preferences: \(String(describing: try? settings.get(key: "preferences").compactJson()))")
+}
+
+try await settings.set(key: "theme", value: "dark")
+try await settings.get(key: "preferences").asLiveMap().set(key: "language", value: "en")
+```
+
```java
LiveMapPathObject settings = rootObject.get("settings").asLiveMap();
@@ -1016,6 +1400,17 @@ settings.subscribe(({ object }) => {
}, { depth: 1 });
```
+```swift
+let settings = rootObject.get(key: "settings").asLiveMap()
+
+// Only observe direct changes to the settings LiveMap
+// Changes to any nested objects are ignored
+try settings.subscribe(options: PathObjectSubscriptionOptions(depth: 1)) { event in
+ print("Settings updated")
+ print("Changed path: \(event.object.path)") // Always "settings"
+}
+```
+
```java
LiveMapPathObject settings = rootObject.get("settings").asLiveMap();
@@ -1028,6 +1423,9 @@ settings.subscribe(event -> {
```
+
+Call `subscribe(listener:)` without options, or create `PathObjectSubscriptionOptions` with no arguments, to observe changes at all depths. The depth must be 1 or greater; `subscribe()` throws an `ARTErrorInfo` (status `400`, error code `40003`) for zero or negative values.
+
Create `PathObjectSubscriptionOptions` with no arguments to observe changes at all depths. The depth passed to the constructor must be 1 or greater; zero or negative values are rejected with an `AblyException` (status `400`, error code `40003`).
diff --git a/src/pages/docs/liveobjects/concepts/synchronization.mdx b/src/pages/docs/liveobjects/concepts/synchronization.mdx
index 2ecdeb81d7..3d69c46530 100644
--- a/src/pages/docs/liveobjects/concepts/synchronization.mdx
+++ b/src/pages/docs/liveobjects/concepts/synchronization.mdx
@@ -41,7 +41,7 @@ While Ably maintains the source of truth on the channel, each connected client k
When the client first attaches to the channel, the state of the channel objects is streamed to the client. Large objects, such as maps with many entries, may be delivered across multiple messages during synchronization. [Lifecycle events](/docs/liveobjects/lifecycle#synchronization) allow your application to be notified when the local state is being synchronized with the Ably service.
-
+
@@ -61,7 +61,7 @@ When a client publishes an operation, the operation is applied to its local obje
diff --git a/src/pages/docs/liveobjects/counter.mdx b/src/pages/docs/liveobjects/counter.mdx
index a340a5de63..f99a281738 100644
--- a/src/pages/docs/liveobjects/counter.mdx
+++ b/src/pages/docs/liveobjects/counter.mdx
@@ -25,10 +25,15 @@ meta_description: "Create, update and receive updates for a numerical counter th
`LiveCounter` is a synchronized numerical counter that supports increment and decrement operations. It ensures that all updates are correctly applied and synchronized across clients in realtime, preventing inconsistencies when multiple clients modify the counter value simultaneously.
-
+
You interact with `LiveCounter` through a [PathObject](/docs/liveobjects/concepts/path-object) or by obtaining a specific [Instance](/docs/liveobjects/concepts/instance).
+
+
+
+
+
+
+
+Get a numeric representation of the counter using the `compactJson()` method (the Swift SDK has no `compact()` equivalent). It returns the same result as `value()`:
+
+
+```swift
+// Get a PathObject to a LiveCounter stored in 'visits'
+print(try rootObject.get(key: "visits").compactJson() as Any) // JSONValue?, nil if unresolved; e.g. 5.0
+
+// Get the Instance of a LiveCounter stored in 'visits'
+if case .liveCounter(let counter)? = try rootObject.get(key: "visits").instance() {
+ print(try counter.compactJson()) // non-null JSONValue, e.g. 5.0
+}
+```
+
+
+When calling `compactJson()` on a `LiveMap`, nested `LiveCounter` objects are included as a number:
+
+
+```swift
+// Get a PathObject to a LiveMap stored in 'stats'
+print(try rootObject.get(key: "stats").compactJson() as Any) // JSONValue?, nil if unresolved; e.g. {"visits":5.0}
+
+// Get the Instance of a LiveMap stored in 'stats'
+if case .liveMap(let stats)? = try rootObject.get(key: "stats").instance() {
+ print(try stats.compactJson()) // non-null JSONValue, e.g. {"visits":5.0}
+}
+```
+
+
+
Get a numeric representation of the counter using the `compactJson()` method (the Java SDK has no `compact()` equivalent). It returns the same result as `value()`:
@@ -236,6 +326,19 @@ await visitsInstance?.increment(5); // increment by 5
await visitsInstance?.increment(); // increment by 1 (default)
```
+```swift
+// PathObject: increment counter at path
+let visits = rootObject.get(key: "visits").asLiveCounter()
+try await visits.increment(amount: 5) // increment by 5
+try await visits.increment() // increment by 1 (default)
+
+// Instance: increment specific counter instance
+if case .liveCounter(let counter)? = try rootObject.get(key: "visits").instance() {
+ try await counter.increment(amount: 5) // increment by 5
+ try await counter.increment() // increment by 1 (default)
+}
+```
+
```java
// PathObject: increment counter at path
LiveCounterPathObject visits = rootObject.get("visits").asLiveCounter();
@@ -267,6 +370,19 @@ await visitsInstance?.decrement(5); // decrement by 5
await visitsInstance?.decrement(); // decrement by 1 (default)
```
+```swift
+// PathObject: decrement counter at path
+let visits = rootObject.get(key: "visits").asLiveCounter()
+try await visits.decrement(amount: 5) // decrement by 5
+try await visits.decrement() // decrement by 1 (default)
+
+// Instance: decrement specific counter instance
+if case .liveCounter(let counter)? = try rootObject.get(key: "visits").instance() {
+ try await counter.decrement(amount: 5) // decrement by 5
+ try await counter.decrement() // decrement by 1 (default)
+}
+```
+
```java
// PathObject: decrement counter at path
LiveCounterPathObject visits = rootObject.get("visits").asLiveCounter();
@@ -343,6 +459,27 @@ if (visitsInstance) {
}
```
+```swift
+// PathObject: observe location - tracks changes even if counter instance is replaced
+let visits = rootObject.get(key: "visits").asLiveCounter()
+let subscription = try visits.subscribe { _ in
+ print("Visits: \(String(describing: try? visits.value()))")
+}
+
+// Later, stop listening to changes
+subscription.unsubscribe()
+
+// Instance: track specific counter instance - follows it even if moved in object tree
+if case .liveCounter(let counter)? = try rootObject.get(key: "visits").instance() {
+ let instanceSub = try counter.subscribe { _ in
+ print("This counter instance updated")
+ }
+
+ // Later, stop listening to changes
+ instanceSub.unsubscribe()
+}
+```
+
```java
// PathObject: observe location - tracks changes even if counter instance is replaced
LiveCounterPathObject visits = rootObject.get("visits").asLiveCounter();
@@ -394,191 +531,12 @@ if (visitsInstance) {
-
-The Java SDK has no `subscribeIterator()` equivalent; a listener plus the returned `Subscription` is the only subscription form.
-
-
-
-
-
-## Create LiveCounter
-
-A `LiveCounter` instance can be created using the `channel.objects.createCounter()` method. It must be stored inside a `LiveMap` object that is reachable from the [root object](/docs/liveobjects/concepts/objects#root-object).
-
-`channel.objects.createCounter()` is asynchronous, as the client sends the create operation to the Ably system and waits for an acknowledgment of the successful counter creation.
-
-
-
-
-```swift
-let counter = try await channel.objects.createCounter()
-try await root.set("counter", .liveCounter(counter))
-```
-
-
-Optionally, you can specify an initial value when creating the counter:
-
-
-```swift
-let counter = try await channel.objects.createCounter(count: 100) // Counter starts at 100
-```
-
-
-## Get counter value
-
-Get the current value of a counter using the `LiveCounter.value()` method:
-
-
-```swift
-print("Counter value: \(try counter.value)")
-```
-
-
-## Subscribe to data updates
-
-You can subscribe to data updates on a counter to receive realtime changes made by you or other clients.
-
-
-
+Alternatively, every `PathObject` and counter instance exposes an `events()` method returning an [`AsyncStream`](https://developer.apple.com/documentation/swift/asyncstream) that you can consume with `for await`. Breaking out of the loop, or cancelling the enclosing task, automatically unsubscribes. See the [quickstart](/docs/liveobjects/quickstart/swift#step-6) for an example.
-Subscribe to data updates on a counter using the `LiveCounter.subscribe()` method:
-
-
-```swift
-try counter.subscribe { update, _ in
- do {
- print("Counter updated: \(try counter.value)")
- } catch {
- // Error handling of counter.value omitted for brevity
- }
- print("Update details: \(update)")
-}
-```
-
-
-The update object provides details about the change, such as the amount by which the counter value was changed.
-
-It may also include the client ID of the client that made the change, if the change can be attributed to a specific client. For example, the client ID may be missing if the update was triggered by data resynchronization after a disconnection and the change occurred while the client was offline.
-
-Example structure of an update object when the counter was incremented by 5 by a client with the ID `my-client`:
-
-
-```json
-{
- "update": {
- "amount": 5
- },
- "clientId": "my-client"
-}
-```
-
-
-Or decremented by 10:
-
-
-```json
-{
- "amount": -10
-}
-```
-
-
-### Unsubscribe from data updates
-
-Use the `unsubscribe()` function returned in the `subscribe()` response to remove a counter update listener:
-
-
-```swift
-// Initial subscription
-let subscriptionResponse = try counter.subscribe { _, _ in
- do {
- print(try counter.value)
- } catch {
- // Error handling of counter.value omitted for brevity
- }
-}
-// To remove the listener
-subscriptionResponse.unsubscribe()
-```
-
-
-
-To remove a counter update listener from _inside_ the listener function, you can call `unsubscribe()` on the subscription response that is passed as the second argument to the listener function:
-
-
-```swift
-try counter.subscribe { _, subscriptionResponse in
- // Remove the listener so that this callback
- // no longer gets called
- subscriptionResponse.unsubscribe()
-}
-```
-
+
+The Java SDK has no `subscribeIterator()` equivalent; a listener plus the returned `Subscription` is the only subscription form.
-Use the `LiveCounter.unsubscribeAll()` method to deregister all counter update listeners:
-
-
-```swift
-counter.unsubscribeAll();
-```
-
-
-## Update LiveCounter
-
-Update the counter value by calling `LiveCounter.increment(amount:)` or `LiveCounter.decrement(amount:)`. These operations are synchronized across all clients and trigger data subscription callbacks for the counter, including on the client making the request.
-
-These operations are asynchronous, as the client sends the corresponding update operation to the Ably system and waits for acknowledgment of the successful counter update.
-
-
-```swift
-try await counter.increment(amount: 5) // Increase value by 5
-try await counter.decrement(amount: 2) // Decrease value by 2
-```
-
-
-## Subscribe to lifecycle events
-
-Subscribe to lifecycle events on a counter using the `LiveCounter.on(event:callback:)` method:
-
-
-```swift
-counter.on(event: .deleted) { _ in
- print("Counter has been deleted")
-}
-```
-
-
-Read more about [objects lifecycle events](/docs/liveobjects/lifecycle#objects).
-
-### Unsubscribe from lifecycle events
-
-Use the `off()` function returned in the `on()` response to remove a lifecycle event listener:
-
-
-```swift
-// Initial subscription
-let eventResponse = counter.on(event: .deleted) { _ in
- print("Counter deleted")
-}
-// To remove the listener
-eventResponse.off()
-```
-
-
-Use the `LiveCounter.offAll()` method to deregister all lifecycle event listeners:
-
-
-
-```swift
-counter.offAll()
-```
-
-
diff --git a/src/pages/docs/liveobjects/inband-objects.mdx b/src/pages/docs/liveobjects/inband-objects.mdx
index 34d2b9c88e..63aedd75af 100644
--- a/src/pages/docs/liveobjects/inband-objects.mdx
+++ b/src/pages/docs/liveobjects/inband-objects.mdx
@@ -67,6 +67,22 @@ const channel = realtime.channels.get('my-channel', channelOpts);
await channel.setOptions({ params: { objects: 'objects' } });
```
+```swift
+// When getting a channel instance
+// Enable full objects sync mode
+let options = ARTRealtimeChannelOptions()
+options.params = ["objects": "objects"]
+let channel = realtime.channels.get("my-channel", options: options)
+
+// Or enable notification mode
+let notificationOptions = ARTRealtimeChannelOptions()
+notificationOptions.params = ["objects": "notification"]
+let notificationChannel = realtime.channels.get("my-channel", options: notificationOptions)
+
+// Or using setOptions on an existing channel
+channel.setOptions(options, callback: nil)
+```
+
```java
// When getting a channel instance
// Enable full objects sync mode
@@ -95,7 +111,7 @@ Individual objects sent via inband objects mode cannot exceed the maximum messag
Inband objects are delivered as regular channel messages, which are subject to the standard message size constraints.
To avoid hitting this limit when using inband objects:
@@ -111,7 +127,7 @@ The client receives `[meta]objects` messages whenever the objects on the channel
If there is a high rate of updates to the channel objects the inband messages are throttled. However in the case of `objects` mode, the client is guaranteed to receive a sequence of inband messages after the last change occurs so that the latest data is always available.
-[Subscribe](/docs/pub-sub/api/javascript/realtime/realtime-channel#subscribe)[Subscribe](/docs/api/realtime-sdk/channels#subscribe) to `[meta]objects` messages like you would any other message on the channel. For convenience, use a message name filter to only receive messages with the name `[meta]objects` in your listener:
+[Subscribe](/docs/pub-sub/api/javascript/realtime/realtime-channel#subscribe)[Subscribe](/docs/api/realtime-sdk/channels?lang=swift#subscribe)[Subscribe](/docs/api/realtime-sdk/channels#subscribe) to `[meta]objects` messages like you would any other message on the channel. For convenience, use a message name filter to only receive messages with the name `[meta]objects` in your listener:
```javascript
@@ -121,6 +137,13 @@ channel.subscribe('[meta]objects', (message) => {
});
```
+```swift
+// Subscribe to [meta]objects messages
+channel.subscribe("[meta]objects") { message in
+ print("Received inband objects message: \(message.data)")
+}
+```
+
```java
// Subscribe to [meta]objects messages
channel.subscribe("[meta]objects", message ->
@@ -149,6 +172,17 @@ Enable objects mode using the `objects` [channel parameter](/docs/channels/optio
});
```
+```swift
+let options = ARTRealtimeChannelOptions()
+options.params = ["objects": "objects"]
+let channel = realtime.channels.get("my-channel", options: options)
+
+// Subscribe to [meta]objects messages in objects mode
+channel.subscribe("[meta]objects") { message in
+ print("Received object: \(message.data)")
+}
+```
+
```java
ChannelOptions options = new ChannelOptions();
options.params = Map.of("objects", "objects");
@@ -175,7 +209,7 @@ The message `data` is a JSON object with the following top-level properties:
| `nextCursor` | A cursor for the next message in the sequence, or `undefined` if this is the last message in the sequence. |
| `object` | A JSON representation of the object included in the message. |
-The shape of the `object` is the same as the response format of an object when listing them via the [REST API](/docs/liveobjects/rest-api-usage#fetching-objects-list-values):
+The shape of the `object` is the same as the response format of an object when listing them via the [REST API](/docs/liveobjects/rest-api-usage#fetching-objects):
**Inband Counter Message**
@@ -259,6 +293,12 @@ const channelOpts = { params: { objects: 'notification' } };
const channel = realtime.channels.get('my-channel', channelOpts);
```
+```swift
+let options = ARTRealtimeChannelOptions()
+options.params = ["objects": "notification"]
+let channel = realtime.channels.get("my-channel", options: options)
+```
+
```java
ChannelOptions options = new ChannelOptions();
options.params = Map.of("objects", "notification");
@@ -278,7 +318,7 @@ The message `data` is a JSON object with the following property:
| Property | Description |
| -------- | ----------- |
-| `link` | A relative URL path to fetch the current state of the channel object on the channel via the [REST API](/docs/liveobjects/rest-api-usage#fetch-channel-object). This can be used directly with the Ably REST client's [request](/docs/pub-sub/api/javascript/rest/rest-client#request)[request](/docs/api/rest-sdk#request) method. |
+| `link` | A relative URL path to fetch the current state of the channel object on the channel via the [REST API](/docs/liveobjects/rest-api-usage#fetch-channel-object). This can be used directly with the Ably REST client's [request](/docs/pub-sub/api/javascript/rest/rest-client#request)[request](/docs/api/rest-sdk?lang=swift#request)[request](/docs/api/rest-sdk#request) method. |
@@ -296,6 +336,26 @@ channel.subscribe('[meta]objects', async (message) => {
});
```
+```swift
+// Subscribe to [meta]objects messages in notification mode
+channel.subscribe("[meta]objects") { message in
+ guard let data = message.data as? [String: Any],
+ let link = data["link"] as? String else { return }
+ print("Objects operation received, fetching state from: \(link)")
+
+ // Fetch the current state using the REST API
+ do {
+ try ablyRest.request("GET", path: link, params: nil, body: nil, headers: nil) { response, error in
+ if let items = response?.items, items.count > 0 {
+ print("Current state: \(items[0])")
+ }
+ }
+ } catch {
+ print("Failed to fetch state: \(error)")
+ }
+}
+```
+
```java
// Subscribe to [meta]objects messages in notification mode
channel.subscribe("[meta]objects", message -> {
diff --git a/src/pages/docs/liveobjects/index.mdx b/src/pages/docs/liveobjects/index.mdx
index 7403bddfb6..6b8d81f612 100644
--- a/src/pages/docs/liveobjects/index.mdx
+++ b/src/pages/docs/liveobjects/index.mdx
@@ -80,7 +80,7 @@ LiveObjects enables you to build complex, hierarchical data structures through [
### Batch operations
-[Batch operations](/docs/liveobjects/batch) enables multiple operations to be grouped into a single channel message, ensuring atomic application of grouped operations. This prevents partial updates of your data and ensures consistency across all users. Batch operations are not yet supported in the Java SDK; use [batch operations via the LiveObjects REST API](/docs/liveobjects/rest-api-usage#batch-operations) instead.
+[Batch operations](/docs/liveobjects/batch) enable multiple operations to be grouped into a single channel message, ensuring atomic application of grouped operations. This prevents partial updates of your data and ensures consistency across all users. Batch operations are not supported in LiveObjects Swift; use [batch operations via the LiveObjects REST API](/docs/liveobjects/rest-api-usage#batch-operations) instead. Batch operations are not yet supported in the Java SDK; use [batch operations via the LiveObjects REST API](/docs/liveobjects/rest-api-usage#batch-operations) instead.
### Inband objects
diff --git a/src/pages/docs/liveobjects/lifecycle.mdx b/src/pages/docs/liveobjects/lifecycle.mdx
index 10e2694f18..fa9d4629c0 100644
--- a/src/pages/docs/liveobjects/lifecycle.mdx
+++ b/src/pages/docs/liveobjects/lifecycle.mdx
@@ -25,7 +25,7 @@ meta_description: "Understand lifecycle events for Objects, LiveMap and LiveCoun
## Handle synchronization events
-The [channel.object](/docs/liveobjects/concepts/objects#channel-object)[channel.objects](/docs/liveobjects/concepts/objects#root-object) instance emits synchronization events that indicate when the local state on the client is being synchronized with the Ably service. These events can be useful for displaying loading indicators, preventing user interactions during synchronization, or triggering application logic when data is first loaded.
+The [channel.object](/docs/liveobjects/concepts/objects#channel-object)[channel.object](/docs/liveobjects/concepts/objects?lang=swift#channel-object) instance emits synchronization events that indicate when the local state on the client is being synchronized with the Ably service. These events can be useful for displaying loading indicators, preventing user interactions during synchronization, or triggering application logic when data is first loaded.
| Event | Description |
| ----- | ----------- |
@@ -46,14 +46,14 @@ channel.object.on('synced', () => {
```
```swift
-channel.objects.on(event: .syncing) { _ in
- print("objects are syncing...")
- // show a loading indicator and disable edits in the application
+channel.object.on(event: .syncing) {
+ print("Objects are syncing...")
+ // Show a loading indicator and disable edits in the application
}
-channel.objects.on(event: .synced) { _ in
- print("objects have been synced.")
- // hide loading indicator
+channel.object.on(event: .synced) {
+ print("Objects have been synced.")
+ // Hide loading indicator
}
```
@@ -73,6 +73,9 @@ channel.object.on(ObjectStateEvent.SYNCED, stateEvent -> {
The `on(...)` method returns a `Subscription` object. Keep a reference to it and call `subscription.unsubscribe()` when you no longer want to receive these events; calling it more than once has no effect. To remove all listeners at once, call `channel.object.offAll()`. The `ObjectStateEvent` enum is in the `io.ably.lib.liveobjects.state` package.
+
+The `on(event:)` method returns an `any StatusSubscription`. Keep a reference to it and call `subscription.off()` when you no longer want to receive these events. There is no bulk removal method; remove each subscription individually through its own `off()`.
+
-
-
-
-Lifecycle events enable you to monitor changes in an object's lifecycle.
-
-Currently, only the `deleted` event can be emitted. Understanding the conditions under which this event is emitted and handling it properly ensures that your application maintains expected behavior.
-
-### deleted event
-
-Objects that were created on a channel can become orphaned when they were never assigned to the object tree, or because their reference was removed using [`LiveMap.remove()`](/docs/liveobjects/map#remove) and never reassigned. Ably garbage collects orphaned objects, typically after 24 hours. When this happens, a `deleted` event is broadcast for the affected object. Once deleted, an object can no longer be interacted with, and any operations performed on it result in an error.
-
-While the LiveObjects feature internally manages object deletions and removes them from its internal state, your application may still hold references to these deleted objects in separate data structures. The `deleted` event provides a way to react accordingly by removing references to deleted objects and preventing potential errors.
-
-In most cases, subscribing to `deleted` events is unnecessary. Your application should have already reacted to object removal when a corresponding [`LiveMap.remove()`](/docs/liveobjects/map#remove) operation was received. However, if your application separately stores references to object instances and does not properly clear them when objects are orphaned, any later interactions with those objects after they are deleted result in an error. In such cases, subscribing to `deleted` events helps ensure that those references are cleaned up and runtime errors are avoided.
-
-
-
-
-```swift
-counter.on(event: .deleted) { _ in
- print("LiveCounter has been deleted.")
- // Remove references to this object from your application
- // as it can no longer be interacted with
-}
-```
-
-
-Read more about subscribing to object lifecycle events for [LiveCounter](/docs/liveobjects/counter#subscribe-lifecycle) and [LiveMap](/docs/liveobjects/map#subscribe-lifecycle).
-
-
diff --git a/src/pages/docs/liveobjects/map.mdx b/src/pages/docs/liveobjects/map.mdx
index 85ebbbc476..531c3e79e8 100644
--- a/src/pages/docs/liveobjects/map.mdx
+++ b/src/pages/docs/liveobjects/map.mdx
@@ -25,10 +25,15 @@ meta_description: "Create, update and receive updates for a key/value data struc
`LiveMap` is a synchronized key/value data structure that stores [primitive values](/docs/liveobjects/concepts/objects#primitive-types), such as numbers, strings, booleans, binary data, JSON-serializable objects or arrays and other live [object types](/docs/liveobjects/concepts/objects#object-types). It ensures that all updates are correctly applied and synchronized across clients in realtime, automatically resolving conflicts with last-write-wins (LWW) semantics.
-
+
You interact with `LiveMap` through a [PathObject](/docs/liveobjects/concepts/path-object) or by obtaining a specific [Instance](/docs/liveobjects/concepts/instance).
+
+
+
+
+- For entries containing [primitive values](/docs/liveobjects/concepts/objects#primitive-types) or [`LiveCounter`](/docs/liveobjects/counter) objects, infer the matching type and read its value (`asPrimitive().value()`, `asLiveCounter().value()`).
+- For entries containing nested `LiveMap` objects, cast with `asLiveMap()` to continue navigating deeper with `get(key:)`.
+
- For entries containing [primitive values](/docs/liveobjects/concepts/objects#primitive-types) or [`LiveCounter`](/docs/liveobjects/counter) objects, infer the matching type and read its value (`asString().value()`, `asLiveCounter().value()`, and so on).
- For entries containing nested `LiveMap` objects, cast with `asLiveMap()` to continue navigating deeper with `get()`.
@@ -158,6 +213,22 @@ const settingsInstance = rootObject.get('settings').instance();
console.log(settingsInstance?.get('theme')?.value()); // e.g. 'dark' (primitive string)
```
+```swift
+let rootObject = try await channel.object.get()
+
+// PathObject access: path-based operations that resolve at runtime
+let theme = rootObject.get(key: "settings").asLiveMap().get(key: "theme")
+print(try theme.asPrimitive().value()?.stringValue ?? "") // e.g. "dark"
+print(try rootObject.get(key: "visits").asLiveCounter().value() ?? 0) // e.g. 5.0
+
+// Instance access: reference to a specific map object
+if case .liveMap(let settingsInstance)? = try rootObject.get(key: "settings").instance() {
+ if case .primitive(let themeInstance)? = try settingsInstance.get(key: "theme") {
+ print(try themeInstance.value.stringValue ?? "") // e.g. "dark" (primitive string)
+ }
+}
+```
+
```java
LiveMapPathObject rootObject = channel.object.get().join();
@@ -182,6 +253,11 @@ if (settingsInstance != null) {
Calling `value()` on a `PathObject` may return `undefined` if a primitive value or `LiveCounter` instance cannot be found at that path. Calling `value()` on an `Instance` returns `undefined` if the instance is not a primitive or `LiveCounter`. Learn more about reading values on a [`PathObject`](/docs/liveobjects/concepts/path-object#read-values) and [`Instance`](/docs/liveobjects/concepts/instance#read-values).
+
+
+
+
+Get a JSON representation of the map using the `compactJson()` method (the Swift SDK has no `compact()` equivalent):
+
+
+```swift
+// Get a PathObject to a LiveMap stored in 'settings'
+print(try rootObject.get(key: "settings").compactJson() as Any) // JSONValue, nil if unresolved
+// e.g. {"theme":"dark","fontSize":14.0,"notifications":true}
+
+// Get the Instance of a LiveMap stored in 'settings'
+if case .liveMap(let settingsInstance)? = try rootObject.get(key: "settings").instance() {
+ print(try settingsInstance.compactJson()) // non-optional JSONValue
+}
+```
+
+
+
Get a JSON representation of the map using the `compactJson()` method (the Java SDK has no `compact()` equivalent):
@@ -256,6 +349,30 @@ await settingsInstance?.set('theme', 'dark');
await settingsInstance?.set('fontSize', 14);
```
+```swift
+// PathObject: set values at path
+let settings = rootObject.get(key: "settings").asLiveMap()
+try await settings.set(key: "theme", value: "dark")
+try await settings.set(key: "fontSize", value: 14)
+try await settings.set(key: "notifications", value: true)
+
+// Set a nested map
+let advanced = LiveMap.create(entries: [
+ "debugMode": false,
+ "logLevel": "info"])
+try await settings.set(key: "advanced", value: .liveMap(advanced))
+
+// Set a counter
+let visitsCounter = LiveCounter.create(initialCount: 0)
+try await settings.set(key: "visits", value: .liveCounter(visitsCounter))
+
+// Instance: set values on specific map instance
+if case .liveMap(let settingsInstance)? = try rootObject.get(key: "settings").instance() {
+ try await settingsInstance.set(key: "theme", value: "dark")
+ try await settingsInstance.set(key: "fontSize", value: 14)
+}
+```
+
```java
// PathObject: set values at path
LiveMapPathObject settings = rootObject.get("settings").asLiveMap();
@@ -300,6 +417,16 @@ const settingsInstance = rootObject.get('settings').instance();
await settingsInstance?.remove('oldSetting');
```
+```swift
+// PathObject: remove key at path
+try await rootObject.get(key: "settings").asLiveMap().remove(key: "oldSetting")
+
+// Instance: remove key on specific map instance
+if case .liveMap(let settingsInstance)? = try rootObject.get(key: "settings").instance() {
+ try await settingsInstance.remove(key: "oldSetting")
+}
+```
+
```java
// PathObject: remove key at path
rootObject.get("settings").asLiveMap().remove("oldSetting").join();
@@ -327,6 +454,17 @@ const settingsInstance = rootObject.get('settings').instance();
console.log(settingsInstance?.size()); // e.g. 5
```
+```swift
+// PathObject: get size at path - Int?, or nil when not a LiveMap
+let size = try rootObject.get(key: "settings").asLiveMap().size()
+print(size as Any) // e.g. 5
+
+// Instance: get size of specific map instance - non-optional
+if case .liveMap(let settingsInstance)? = try rootObject.get(key: "settings").instance() {
+ print(try settingsInstance.size) // e.g. 5
+}
+```
+
```java
// PathObject: get size at path - Long, or null when not a LiveMap
Long size = rootObject.get("settings").asLiveMap().size();
@@ -345,6 +483,11 @@ if (settingsInstance != null) {
Calling `size()` on a `PathObject` may return `undefined` if a `LiveMap` instance cannot be found at that path. Calling `size()` on an `Instance` returns `undefined` if the instance is not a `LiveMap`.
+
+
+
-
-The Java SDK has no `subscribeIterator()` equivalent; a listener plus the returned `Subscription` is the only subscription form.
-
-
-
-
-
-## Create LiveMap
-
-A `LiveMap` instance can be created using the `channel.objects.createMap()` method. It must be stored inside another `LiveMap` object that is reachable from the [root object](/docs/liveobjects/concepts/objects#root-object).
-
-`channel.objects.createMap()` is asynchronous, as the client sends the create operation to the Ably system and waits for an acknowledgment of the successful map creation.
-
-
-
-
-```swift
-let map = try await channel.objects.createMap()
-try await root.set(key: "myMap", value: .liveMap(map))
-```
-
-
-Optionally, you can specify an initial key/value structure when creating the map:
-
-
-```swift
-// Pass a Dictionary reflecting the initial state
-let map = try await channel.objects.createMap(entries: ["foo": "bar", "baz": 42])
-// You can also pass other objects as values for keys
-try await channel.objects.createMap(entries: ["nestedMap": .liveMap(map)])
-```
-
-
-## Get value for a key
-
-Get the current value for a key in a map using the `LiveMap.get()` method:
-
-
-```swift
-let value = try map.get(key: "my-key")
-print("Value for my-key: \(String(describing: value))")
-```
-
-
-## Subscribe to data updates
-
-You can subscribe to data updates on a map to receive realtime changes made by you or other clients.
-
-
-
+Alternatively, both `LiveMapPathObject` and `LiveMapInstance` expose an `events()` method returning an [`AsyncStream`](https://developer.apple.com/documentation/swift/asyncstream) that you can consume with `for await`. Breaking out of the loop, or cancelling the enclosing task, automatically unsubscribes. See the [quickstart](/docs/liveobjects/quickstart/swift#step-6) for an example.
-
-Subscribe to data updates on a map using the `LiveMap.subscribe()` method:
-
-
-```swift
-try map.subscribe { update, _ in
- do {
- print("Map updated: \(try map.entries)")
- } catch {
- // Error handling of map.entries omitted for brevity
- }
- print("Update details: \(update)")
-}
-```
-
-
-The update object provides details about the change, listing the keys that were changed and indicating whether they were updated (value changed) or removed from the map.
-
-It may also include the client ID of the client that made the change, if the change can be attributed to a specific client. For example, the client ID may be missing if the update was triggered by data resynchronization after a disconnection and the change occurred while the client was offline.
-
-Example structure of an update object when the key `foo` is updated and the key `bar` is removed, made by a client with the ID `my-client`:
-
-
-```json
-{
- "update": {
- "foo": "updated",
- "bar": "removed"
- },
- "clientId": "my-client"
-}
-```
-
-
-### Unsubscribe from data updates
-
-Use the `unsubscribe()` function returned in the `subscribe()` response to remove a map update listener:
-
-
-```swift
-// Initial subscription
-let subscriptionResponse = try map.subscribe { _, _ in
- print("Map updated")
-}
-// To remove the listener
-subscriptionResponse.unsubscribe()
-```
-
-
-
-To remove a map update listener from _inside_ the listener function, you can call `unsubscribe()` on the subscription response that is passed as the second argument to the listener function:
-
-
-```swift
-try map.subscribe { _, subscriptionResponse in
- // Remove the listener so that this callback
- // no longer gets called
- subscriptionResponse.unsubscribe()
-}
-```
-
-
-
-Use the `LiveMap.unsubscribeAll()` method to deregister all map update listeners:
-
-
-```swift
-map.unsubscribeAll();
-```
-
-
-## Set keys in a LiveMap
-
-Set a value for a key in a map by calling `LiveMap.set(key:value:)`. This operation is synchronized across all clients and triggers data subscription callbacks for the map, including on the client making the request.
-
-Keys in a map can contain numbers, strings, booleans, `Data`, JSON-serializable objects or arrays and other `LiveMap` and `LiveCounter` objects.
-
-This operation is asynchronous, as the client sends the corresponding update operation to the Ably system and waits for acknowledgment of the successful map key update.
-
-
-```swift
-try await map.set(key: "foo", value: "bar")
-try await map.set(key: "baz", value: 42)
-
-// Can also set a reference to another object
-let counter = try await channel.objects.createCounter()
-try await map.set(key: "counter", value: .liveCounter(counter))
-```
-
-
-## Remove a key from a LiveMap
-
-Remove a key from a map by calling `LiveMap.remove(key:)`. This operation is synchronized across all clients and triggers data subscription callbacks for the map, including on the client making the request.
-
-This operation is asynchronous, as the client sends the corresponding remove operation to the Ably system and waits for acknowledgment of the successful map key removal.
-
-
-```swift
-try await map.remove(key: "foo")
-```
-
-
-## Get the number of entries in a LiveMap
-
-Get the number of entries in a map using `LiveMap.size`:
-
-
-```swift
-let map = try await channel.objects.createMap(entries: ["foo": "bar", "baz": "qux"])
-print(try map.size) // e.g. 2
-```
-
-
-## Iterate over key/value pairs
-
-Iterate over key/value pairs, keys or values using the `LiveMap.entries`, `LiveMap.keys` and `LiveMap.values` properties respectively.
-
-
-These properties are all `Array`-valued. Note that they do not guarantee that entries are returned in insertion order.
+
+The Java SDK has no `subscribeIterator()` equivalent; a listener plus the returned `Subscription` is the only subscription form.
-
-```swift
-for (key, value) in try map.entries {
- print("Key: \(key), Value: \(value)")
-}
-
-for key in try map.keys {
- print("Key: \(key)")
-}
-
-for value in try map.values {
- print("Value: \(value)")
-}
-```
-
-
-## Subscribe to lifecycle events
-
-Subscribe to lifecycle events on a map using the `LiveMap.on(event:callback:)` method:
-
-
-```swift
-map.on(event: .deleted) { _ in
- print("Map has been deleted")
-}
-```
-
-
-Read more about [objects lifecycle events](/docs/liveobjects/lifecycle#objects).
-
-### Unsubscribe from lifecycle events
-
-Use the `off()` function returned in the `on()` response to remove a lifecycle event listener:
-
-
-```swift
-// Initial subscription
-let eventResponse = map.on(event: .deleted) { _ in
- print("Map deleted")
-}
-// To remove the listener
-eventResponse.off()
-```
-
-
-Use the `LiveMap.offAll()` method to deregister all lifecycle event listeners:
-
-
-```swift
-map.offAll()
-```
-
-
-## Create nested structures
-
-A `LiveMap` can store other `LiveMap` or `LiveCounter` objects as values for its keys, enabling you to build complex, hierarchical object structure. This enables you to represent complex data models in your applications while ensuring realtime synchronization across clients.
-
-
-```swift
-// Create a hierarchy of objects using LiveMap
-let counter = try await channel.objects.createCounter()
-let map = try await channel.objects.createMap(entries: ["nestedCounter": .liveCounter(counter)])
-let outerMap = try await channel.objects.createMap(entries: ["nestedMap": .liveMap(map)])
-try await root.set(key: "outerMap", value: .liveMap(outerMap))
-
-// resulting structure:
-// root (LiveMap)
-// └── outerMap (LiveMap)
-// └── nestedMap (LiveMap)
-// └── nestedCounter (LiveCounter)
-```
-
-
diff --git a/src/pages/docs/liveobjects/quickstart/swift.mdx b/src/pages/docs/liveobjects/quickstart/swift.mdx
index d4b19d45a5..6475ca9aac 100644
--- a/src/pages/docs/liveobjects/quickstart/swift.mdx
+++ b/src/pages/docs/liveobjects/quickstart/swift.mdx
@@ -16,7 +16,8 @@ You will learn how to:
* Create an Ably account and get an API key for authentication.
* Install the Ably Pub/Sub SDK.
* Create a channel with LiveObjects functionality enabled.
-* Create, update and subscribe to changes on LiveObjects data structures: [LiveMap](/docs/liveobjects/map) and [LiveCounter](/docs/liveobjects/counter).
+* Use the [PathObject API](/docs/liveobjects/concepts/path-object?lang=swift) to access objects on a channel.
+* Create, update and subscribe to changes on LiveObjects data structures: [LiveMap](/docs/liveobjects/map?lang=swift) and [LiveCounter](/docs/liveobjects/counter?lang=swift).
## Authentication
@@ -30,7 +31,7 @@ The examples use [basic authentication](/docs/auth/basic) to demonstrate feature
API keys and tokens have a set of [capabilities](/docs/auth/capabilities) assigned to them that specify which operations can be performed on which resources. The following capabilities are available for LiveObjects:
-* `object-subscribe` - grants clients read access to LiveObjects, allowing them to get the root object and subscribe to updates.
+* `object-subscribe` - grants clients read access to LiveObjects, allowing them to get the channel object and subscribe to updates.
* `object-publish` - grants clients write access to LiveObjects, allowing them to perform mutation operations on objects.
To use LiveObjects, an API key must have at least the `object-subscribe` capability. With only this capability, clients will have read-only access, preventing them from calling mutation methods on LiveObjects.
@@ -39,18 +40,21 @@ For the purposes of this guide, make sure your API key includes both `object-sub
## Install Ably Pub/Sub SDK
-LiveObjects is available as part of the Ably Pub/Sub SDK via the dedicated LiveObjects plugin.
+LiveObjects is available as part of the Ably Pub/Sub SDK via the dedicated LiveObjects plugin. The plugin is distributed inside the `ably-cocoa` package and is available through Swift Package Manager only.
-### Xcode
+The LiveObjects plugin requires a minimum deployment target of iOS 14, macOS 11, or tvOS 14, and Xcode 16.3 or later.
+
+### Follow along in Xcode
To follow this guide in Xcode, use a new iOS project with the SwiftUI App template. All code can be added directly to your `ContentView.swift` file, inside the `ContentView` struct. Use the `.task` modifier with a `do { … } catch { … }` inside to run the Ably code when the view appears. No additional files or setup are needed. All `print` output will appear in Xcode's debug console (View > Debug Area > Activate Console, or press Cmd+Shift+C).
-Install the Ably SDK and the LiveObjects plugin in your Xcode project:
+### Install with Swift Package Manager
+
+Add the Ably SDK and the LiveObjects plugin to your Xcode project:
-1. Paste `https://github.com/ably/ably-cocoa` in the Swift Packages search box (File → Add Package Dependencies).
-2. Add the `Ably` product.
-3. Paste `https://github.com/ably/ably-liveobjects-swift-plugin` in the Swift Packages search box.
-4. Add the `AblyLiveObjects` product.
+1. Select File → Add Package Dependencies.
+2. Paste `https://github.com/ably/ably-cocoa` in the search box.
+3. Add both the `Ably` and `AblyLiveObjects` products to your target.
Import the SDK and the LiveObjects plugin into your project:
@@ -70,132 +74,170 @@ Instantiate an Ably Realtime client from the Pub/Sub SDK, providing the LiveObje
let clientOptions = ARTClientOptions(key: "{{API_KEY}}")
clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self]
-let realtimeClient = ARTRealtime(options: clientOptions)
+let realtime = ARTRealtime(options: clientOptions)
```
-A [`ClientOptions`](/docs/api/realtime-sdk#client-options) object may be passed to the Pub/Sub SDK instance to further customize the connection, however at a minimum you must set an API key and provide the `.liveObjects` plugin so that the client can use LiveObjects functionality.
+A [`ClientOptions`](/docs/api/realtime-sdk?lang=swift#client-options) object may be passed to the Pub/Sub SDK instance to further customize the connection, however at a minimum you must set an API key and register the LiveObjects plugin through `clientOptions.plugins` so that the client can use LiveObjects functionality.
## Create a channel
-LiveObjects is managed and persisted on [channels](/docs/channels). To use LiveObjects, you must first create a channel with the correct [channel mode flags](/docs/channels/options#modes):
+LiveObjects is managed and persisted on [channels](/docs/channels?lang=swift). To use LiveObjects, you must first create a channel with the correct [channel mode flags](/docs/channels/options?lang=swift#modes):
* `OBJECT_SUBSCRIBE` - required to access objects on a channel.
* `OBJECT_PUBLISH` - required to create and modify objects on a channel.
```swift
let channelOptions = ARTRealtimeChannelOptions()
channelOptions.modes = [.objectPublish, .objectSubscribe]
-let channel = realtimeClient.channels.get("my_liveobjects_channel", options: channelOptions)
+let channel = realtime.channels.get("test-channel", options: channelOptions)
```
-Next, you need to [attach to the channel](/docs/channels/states). Attaching to a channel starts an initial synchronization sequence where the objects on the channel are sent to the client.
+## Get the channel object
+
+The [`channel.object`](/docs/api/realtime-sdk/channels?lang=swift#object) property gives access to the LiveObjects API for a channel.
+
+Use `channel.object.get()` to obtain the channel object. The channel object is a [`LiveMap`](/docs/liveobjects/map?lang=swift) that always exists on a channel and acts as the top-level entry point for accessing and persisting objects. It is returned as a [`LiveMapPathObject`](/docs/liveobjects/concepts/path-object?lang=swift#get), which provides a path-based API for accessing and manipulating the object hierarchy.
+
+`channel.object.get()` is an `async` function that suspends with `try await` until the LiveObjects state is synchronized with the Ably system. Reads are synchronous and local:
-{/* This is cumbersome and we'd really like to just write `try await channel.attach()`; see https://github.com/ably/ably-cocoa/issues/2087 */}
```swift
-try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
- channel.attach { error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume()
- }
- }
-}
+let rootObject = try await channel.object.get()
```
-## Get root object
+
+
+
-The [`channel.objects`](/docs/api/realtime-sdk/channels#objects) property gives access to the LiveObjects API for a channel.
+## Create and assign objects
-Use it to get the root object, which is the entry point for accessing and persisting objects on a channel. The root object is a [`LiveMap`](/docs/liveobjects/map) instance that always exists on a channel and acts as the top-level node in your object tree. You can get the root object using the `getRoot()` function of LiveObjects:
+You can create and assign objects using the `LiveMap.create()` and `LiveCounter.create()` static methods. These methods return a blueprint describing the object to create; the actual object is created when you assign it to a key with `set()`. Wrap a blueprint in `.liveCounter(...)` or `.liveMap(...)` when passing it to `set()`, while primitive values such as strings and numbers can be passed directly:
```swift
-// The getRoot call returns once the LiveObjects state is synchronized with the Ably system
-let root = try await channel.objects.getRoot()
+// Create a LiveCounter with initial value 0
+let visits = LiveCounter.create(initialCount: 0)
+
+// Assign it to the 'visits' key on the channel object
+try await rootObject.set(key: "visits", value: .liveCounter(visits))
+
+// Create a LiveMap with initial entries
+let reactions = LiveMap.create(entries: [
+ "likes": 0,
+ "hearts": 0,
+])
+
+// Assign it to the 'reactions' key on the channel object
+try await rootObject.set(key: "reactions", value: .liveMap(reactions))
+
+// Infer types for the assigned objects
+let visitsCounter = rootObject.get(key: "visits").asLiveCounter()
+let reactionsMap = rootObject.get(key: "reactions").asLiveMap()
```
-## Create objects
-
-You can create new objects using dedicated functions of the LiveObjects API at [`channel.objects`](/docs/api/realtime-sdk/channels#objects). To persist them on a channel and share them between clients, you must assign objects to a parent `LiveMap` instance connected to the root object. The root object itself is a `LiveMap` instance, so you can assign objects to the root and start building your object tree from there.
+`rootObject.get(key:)` returns a general [`PathObject`](/docs/liveobjects/concepts/path-object?lang=swift) that doesn't yet know the type of the value it points to. Call one of the `as*` methods, such as `asLiveCounter()` or `asLiveMap()`, to work with the value as a specific type. These casts are always safe to call: they never throw, even if the value at the path has a different type. Learn more about [Type inference](/docs/liveobjects/typing?lang=swift#type-inference).
+## Subscribe to updates
+
+Subscribe to realtime updates using the `subscribe()` method on a `PathObject`. You will be notified when the object at that path is updated by other clients or by you:
+
```swift
-let visitsCounter = try await channel.objects.createCounter()
-let reactionsMap = try await channel.objects.createMap()
+// Subscribe to counter updates
+let counterSubscription = try visitsCounter.subscribe { event in
+ guard let value = try? event.object.asLiveCounter().value() else { return }
+ print("Visits counter updated: \(value)")
+}
-try await root.set(key: "visits", value: .liveCounter(visitsCounter))
-try await root.set(key: "reactions", value: .liveMap(reactionsMap))
+// Subscribe to map updates
+let mapSubscription = try reactionsMap.subscribe { event in
+ guard let json = try? event.object.compactJson() else { return }
+ print("Reactions updated: \(json)")
+}
```
-## Subscribe to updates
+The subscription callback receives an event that exposes:
+- `object`: A `PathObject` pointing to the path where the change occurred.
+- `message`: The `ObjectMessage` that carried the operation that led to the change (nullable).
-Subscribe to realtime updates to objects on a channel. You will be notified when an object is updated by other clients or by you.
+The `subscribe()` method also returns a `Subscription` object. When you no longer want to receive updates, call `unsubscribe()` on it:
```swift
-try visitsCounter.subscribe { _, _ in
- do {
- print("Visits counter updated: \(try visitsCounter.value)")
- } catch {
- // Error handling of visitsCounter.value omitted for brevity
- }
+// Keep the Subscription returned by subscribe()
+let subscription = try visitsCounter.subscribe { event in
+ guard let value = try? event.object.asLiveCounter().value() else { return }
+ print("Visits counter updated: \(value)")
}
-try reactionsMap.subscribe { _, _ in
- do {
- print("Reactions map updated: \(try reactionsMap.entries)")
- } catch {
- // Error handling of reactionsMap.entries omitted for brevity
- }
+// Later, stop receiving updates
+subscription.unsubscribe()
+```
+
+
+Every `PathObject` also exposes an `events()` [`AsyncStream`](https://developer.apple.com/documentation/swift/asyncstream), so you can consume updates with `for await`, which is often the more idiomatic pattern in Swift. Breaking out of the loop, or cancelling the enclosing task, automatically unsubscribes:
+
+
+```swift
+for await event in try visitsCounter.events() {
+ guard let value = try? event.object.asLiveCounter().value() else { continue }
+ print("Visits counter updated: \(value)")
}
```
## Update objects
-Update objects using mutation methods. All subscribers (including you) will be notified of the changes when you update an object:
+Update objects using mutation methods on `PathObject`. All subscribers (including you) will be notified of the changes:
```swift
+// Update counter
try await visitsCounter.increment(amount: 5)
-// console: "Visits counter updated: 5.0"
+// console: Visits counter updated: 5.0
+
try await visitsCounter.decrement(amount: 2)
-// console: "Visits counter updated: 3.0"
-
-try await reactionsMap.set(key: "like", value: 10)
-// console: "Reactions map updated: [(key: "like", value: AblyLiveObjects.LiveMapValue.number(10.0))]"
-try await reactionsMap.set(key: "love", value: 5)
-// console: "Reactions map updated: [(key: "like", value: AblyLiveObjects.LiveMapValue.number(10.0)), (key: "love", value: AblyLiveObjects.LiveMapValue.number(5.0))]"
-try await reactionsMap.remove(key: "like")
-// console: "Reactions map updated: [(key: "love", value: AblyLiveObjects.LiveMapValue.number(5.0))]"
+// console: Visits counter updated: 3.0
+
+// Update map
+try await reactionsMap.set(key: "likes", value: 10)
+// console: Reactions updated: {"likes":10.0,"hearts":0.0}
+
+try await reactionsMap.set(key: "hearts", value: 5)
+// console: Reactions updated: {"likes":10.0,"hearts":5.0}
+
+try await reactionsMap.remove(key: "likes")
+// console: Reactions updated: {"hearts":5.0}
```
## Next steps
-This quickstart introduced the basic concepts of LiveObjects and demonstrated how it works. The next steps are to:
+This quickstart introduced the basic concepts of LiveObjects and demonstrated how the path-based API works. The next steps are to:
-* Read more about [LiveCounter](/docs/liveobjects/counter) and [LiveMap](/docs/liveobjects/map).
-* Learn about [Batching Operations](/docs/liveobjects/batch).
-* Learn about [Objects Lifecycle Events](/docs/liveobjects/lifecycle).
-* Add [Typings](/docs/liveobjects/typing) for your LiveObjects.
+* Learn about the [PathObject](/docs/liveobjects/concepts/path-object?lang=swift) and [Instance](/docs/liveobjects/concepts/instance?lang=swift) APIs.
+* Read more about [LiveCounter](/docs/liveobjects/counter?lang=swift) and [LiveMap](/docs/liveobjects/map?lang=swift).
+* Learn about [Objects Lifecycle Events](/docs/liveobjects/lifecycle?lang=swift).
+* Learn about [Type inference](/docs/liveobjects/typing?lang=swift) for your LiveObjects.
diff --git a/src/pages/docs/liveobjects/storage.mdx b/src/pages/docs/liveobjects/storage.mdx
index a1f08cce18..9f64aa7405 100644
--- a/src/pages/docs/liveobjects/storage.mdx
+++ b/src/pages/docs/liveobjects/storage.mdx
@@ -5,7 +5,7 @@ meta_description: "Learn about LiveObjects object storage."
## Default object storage
-Ably durably stores all objects on a channel for a configurable retention period between 24 hours and 90 days, defaulting to 90 days. If the data is not updated within the retention period, it automatically expires. After expiry, the channel is reset to its initial state and only includes an empty [channel object](/docs/liveobjects/concepts/objects#channel-object)[root object](/docs/liveobjects/concepts/objects#root-object).
+Ably durably stores all objects on a channel for a configurable retention period between 24 hours and 90 days, defaulting to 90 days. If the data is not updated within the retention period, it automatically expires. After expiry, the channel is reset to its initial state and only includes an empty [channel object](/docs/liveobjects/concepts/objects#channel-object).
+
+## Type inference
+
+The Swift SDK doesn't take user-supplied type parameters to describe the structure of your data. Instead, you work with the type of a value differently on each layer:
+
+- On a [`PathObject`](/docs/liveobjects/concepts/path-object?lang=swift), infer the type by calling one of the `as*` methods: `asLiveMap()`, `asLiveCounter()` or `asPrimitive()`. These casts **never throw**. A wrong cast only surfaces later, when you use the result.
+- On an [`Instance`](/docs/liveobjects/concepts/instance?lang=swift), there are no casts at all. `Instance` is an enum, and you discriminate between its cases with an exhaustive `switch` that the compiler checks.
+
+### Cast on the path layer
+
+A `PathObject` is a reference to a location, not to a value, so an `as*` cast can't check what is actually stored at the path. The cast always succeeds, and a mismatch only shows up when you use the result:
+
+- Reads, such as `value()` or `entries()`, return `nil` or an empty result instead of the expected value, and never throw for the mismatch.
+- Writes, such as `set()` or `increment()`, throw an `ARTErrorInfo` from a local check inside the awaited call, before any operation is sent: error code `92007` when the value at the path doesn't match the inferred type, or `92005` when nothing resolves at the path at all.
+
+There is a single primitive cast, `asPrimitive()`, whose `value()` returns a `Primitive` enum that you pattern-match to get the concrete value, or read through convenience getters such as `stringValue` and `numberValue`.
+
+
+```swift
+let rootObject = try await channel.object.get()
+
+// Infer the 'visits' path as a LiveCounter; the cast itself never throws
+let visits = rootObject.get(key: "visits").asLiveCounter()
+
+try await visits.increment(amount: 1)
+
+// A typed read returns the value, or nil when the path is missing
+// or holds a value of a different type
+let theme = try rootObject.at(path: "settings.theme").asPrimitive().value()?.stringValue // String or nil
+```
+
+
+Reads are marked `try` only because they throw when LiveObjects cannot be accessed at all: the channel is in the `DETACHED` or `FAILED` state (error code `90001`), or the channel is missing the `object_subscribe` mode (error code `40024`). They never throw for a type mismatch or an absent path.
+
+### Discriminate on the instance layer
+
+An `Instance` wraps a value that has already been resolved, so its type is known the moment you obtain it. `Instance` is an enum with three cases — `.liveMap`, `.liveCounter` and `.primitive` — so instead of casting, you discriminate between the cases with an exhaustive `switch`. The compiler checks that you handle every case, and the type mismatch error (code `92007`) that the path layer throws for wrong-typed writes cannot occur from this discrimination. In return, reads on a typed instance never return `nil` for a type mismatch: `try map.size` and `try counter.value` are non-optional.
+
+
+```swift
+switch try rootObject.get(key: "score").instance() {
+case .liveCounter(let score):
+ print(try score.value) // non-optional Double
+case .liveMap(let map):
+ print(try map.size)
+case .primitive(let primitive):
+ print(try primitive.value)
+case .none:
+ print("Nothing exists at the 'score' path")
+}
+```
+
+
+Use `if case` when you only need to handle one type:
+
+
+```swift
+if case .liveCounter(let score)? = try rootObject.get(key: "score").instance() {
+ print(try score.value) // non-optional Double
+}
+```
+
+
+### Check a value's type
+
+When you don't know what is stored at a path, check its type before committing to a cast. The path layer exposes `type()` (a method) and the instance layer exposes `type` (a property). Both return a `ValueType` enum with one of the following values: `.string`, `.number`, `.boolean`, `.binary`, `.jsonObject`, `.jsonArray`, `.liveMap`, `.liveCounter` or `.unknown`.
+
+The result tells you slightly different things on each layer:
+
+- On a `PathObject`, `type()` returns `nil` when nothing resolves at the path. This makes the two cases easy to tell apart: `nil` means there is no value at the path, while `.unknown` means a value exists but its type is not recognized.
+- On an `Instance`, the `type` property is non-optional, because an instance always wraps an existing value. It also doesn't return `.unknown` in normal operation.
+- A `PathObject` additionally provides `exists()`, a lightweight, best-effort check for whether anything is currently stored at the path.
+
+On a `PathObject`, `type()` returns an optional; check it for `nil` before acting on it:
+
+
+```swift
+let score = rootObject.get(key: "score")
+
+// type() returns nil when nothing resolves at the path
+if let type = try score.type() {
+ if type == .liveCounter {
+ print(try score.asLiveCounter().value() ?? 0)
+ } else {
+ print(try score.compactJson() as Any)
+ }
+} else {
+ print("Nothing exists at the 'score' path")
+}
+```
+
+
+On an `Instance`, the `type` property is never `nil`, so you can inspect every `ValueType` value directly:
+
+
+```swift
+if let scoreInstance = try rootObject.get(key: "score").instance() {
+ switch scoreInstance.type {
+ case .liveCounter:
+ if case .liveCounter(let counter) = scoreInstance {
+ print(try counter.value)
+ }
+ case .liveMap:
+ if case .liveMap(let map) = scoreInstance {
+ print(try map.size)
+ }
+ case .string, .number, .boolean, .binary, .jsonObject, .jsonArray:
+ // Primitive values are wrapped in read-only primitive instances
+ print(try scoreInstance.compactJson())
+ case .unknown:
+ // Never produced by an Instance in normal operation
+ break
+ }
+}
+```
+
+
+On the write side, `set(key:value:)` takes a `LiveMapValue`. Primitive literals convert automatically through Swift's `ExpressibleBy*Literal` conformances, so passing a `String`, number or `Bool` directly works. To store a nested object, pass a blueprint created with `LiveMap.create(entries:)` or `LiveCounter.create(initialCount:)`, wrapped in the matching `LiveMapValue` case:
+
+
+```swift
+// Primitive literals convert automatically to LiveMapValue
+try await rootObject.set(key: "name", value: "Alice")
+
+// Store a nested object with a blueprint
+let scores = LiveMap.create(entries: ["total": .liveCounter(LiveCounter.create(initialCount: 0))])
+try await rootObject.set(key: "scores", value: .liveMap(scores))
+```
+
+
+Learn more about type inference on the [PathObject](/docs/liveobjects/concepts/path-object?lang=swift#typing) and [Instance](/docs/liveobjects/concepts/instance?lang=swift#typing) concept pages.
+
+
If you are using TypeScript in your project, you can leverage built-in TypeScript support to ensure type safety and enable autocompletion when working with the channel object.