diff --git a/src/data/languages/languageData.ts b/src/data/languages/languageData.ts index 64448e1137..68392ec964 100644 --- a/src/data/languages/languageData.ts +++ b/src/data/languages/languageData.ts @@ -53,7 +53,7 @@ export default { liveObjects: { javascript: '2.21', swift: '0.4', - java: '1.7', + java: '1.8', }, liveSync: { javascript: '0.4', diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index 1b5adcb423..aed0809e2e 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -81,6 +81,7 @@ export default { { name: 'Batch operations', link: '/docs/liveobjects/batch', + languages: ['javascript', 'swift', 'java'], }, { name: 'Lifecycle events', @@ -97,6 +98,7 @@ export default { { name: 'Object storage', link: '/docs/liveobjects/storage', + languages: ['javascript', 'swift', 'java'], }, { name: 'Using the REST SDK', @@ -128,7 +130,8 @@ export default { external: true, }, { - link: 'https://sdk.ably.com/builds/ably/ably-java/main/javadoc/io/ably/lib/objects/RealtimeObjects.html', + // TODO: verify this URL resolves once the path-based API javadoc is published + link: 'https://sdk.ably.com/builds/ably/ably-java/main/javadoc/io/ably/lib/liveobjects/RealtimeObject.html', name: 'Java SDK', external: true, }, diff --git a/src/pages/docs/api/realtime-sdk/channels.mdx b/src/pages/docs/api/realtime-sdk/channels.mdx index ca348025ce..4b296ac968 100644 --- a/src/pages/docs/api/realtime-sdk/channels.mdx +++ b/src/pages/docs/api/realtime-sdk/channels.mdx @@ -114,13 +114,18 @@ Provides access to the [`RealtimeAnnotations`](#realtime-annotations) object for - + #### object + Provides access to the [RealtimeObject](/docs/liveobjects) for this channel which can be used to read, modify and subscribe to LiveObjects on a channel. + +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. diff --git a/src/pages/docs/liveobjects/batch.mdx b/src/pages/docs/liveobjects/batch.mdx index e187720dd2..4d173ce388 100644 --- a/src/pages/docs/liveobjects/batch.mdx +++ b/src/pages/docs/liveobjects/batch.mdx @@ -23,9 +23,19 @@ meta_description: "Group multiple objects operations into a single channel messa + + + + + + + + Batch operations allow multiple updates to be grouped into a single channel message and applied atomically. It ensures that all operations in a batch either succeed together or are discarded entirely. Batching is essential when multiple related updates to channel objects must be applied as a single atomic unit, for example, when application logic depends on multiple objects being updated simultaneously. Batching ensures that all operations in the batch either succeed or fail together. @@ -41,24 +51,24 @@ Call `batch()` on a `PathObject` to group operations on that path: ```javascript -const myObject = await channel.object.get(); +const rootObject = await channel.object.get(); // Batch multiple operations on the channel object -await myObject.batch((ctx) => { +await rootObject.batch((ctx) => { ctx.set('foo', 'bar'); ctx.set('baz', 42); ctx.remove('oldKey'); }); // Batch operations on a nested path -await myObject.get('settings').batch((ctx) => { +await rootObject.get('settings').batch((ctx) => { ctx.set('theme', 'dark'); ctx.set('fontSize', 14); ctx.set('notifications', true); }); // Batch operations on a counter -await myObject.get('visits').batch((ctx) => { +await rootObject.get('visits').batch((ctx) => { ctx.increment(5); ctx.increment(3); ctx.decrement(2); @@ -74,11 +84,11 @@ You can only call `batch()` on an `PathObject` whose path resolves to a `LiveMap ```javascript -const myObject = await channel.object.get(); +const rootObject = await channel.object.get(); try { // Call batch() on 'username', which resolves to a string value - await myObject.get('username').batch((ctx) => {}); + await rootObject.get('username').batch((ctx) => {}); } catch (err) { // Error 92007: Cannot batch operations on a non-LiveObject at path: username } @@ -89,7 +99,7 @@ You can also call `batch()` on an `Instance` to batch operations on that specifi ```javascript -const settingsInstance = myObject.get('settings').instance(); +const settingsInstance = rootObject.get('settings').instance(); if (settingsInstance) { await settingsInstance.batch((ctx) => { @@ -107,9 +117,9 @@ You can create new objects inside a batch using `LiveMap.create()` and `LiveCoun ```javascript -const myObject = await channel.object.get(); +const rootObject = await channel.object.get(); -await myObject.batch((ctx) => { +await rootObject.batch((ctx) => { // Create and assign multiple objects atomically ctx.set('user', LiveMap.create({ name: 'Alice', @@ -137,7 +147,7 @@ Navigate to nested objects using the `get()` method on the batch context. Naviga ```javascript -await myObject.batch((ctx) => { +await rootObject.batch((ctx) => { // Navigate to nested paths and perform operations ctx.get('settings')?.set('theme', 'dark'); ctx.get('settings')?.get('preferences')?.set('language', 'en'); @@ -156,7 +166,7 @@ To explicitly cancel a batch before it is applied, throw an error inside the bat ```javascript try { - await myObject.batch((ctx) => { + await rootObject.batch((ctx) => { // Cancel the entire batch if a required value is missing const value = ctx.get('visits')?.value(); if (value === undefined) { @@ -183,7 +193,7 @@ The batch callback function must be synchronous because the batch method sends t ```javascript try { - await myObject.batch((ctx) => { + await rootObject.batch((ctx) => { // These operations are synchronous and queued ctx.get('settings')?.set('theme', 'dark'); ctx.get('visits')?.increment(); @@ -203,7 +213,7 @@ Since the batch callback is synchronous, you can read current values inside a ba ```javascript -await myObject.batch((ctx) => { +await rootObject.batch((ctx) => { ctx.get('settings')?.get('theme')?.value(); // "dark" // no updates will be applied during execution of the batch callback ctx.get('settings')?.get('theme')?.value(); // "dark" @@ -215,7 +225,7 @@ Operations on a batch context are not applied until they are all published, so y ```javascript -await myObject.batch((ctx) => { +await rootObject.batch((ctx) => { // Get the value at the time batch() was called ctx.get('settings')?.get('theme')?.value(); // "dark" @@ -234,7 +244,7 @@ The batch context object cannot be used outside the callback function. Attemptin ```javascript let context; -await myObject.batch((ctx) => { +await rootObject.batch((ctx) => { context = ctx; ctx.set('foo', 'bar'); }); @@ -253,12 +263,12 @@ When a batch operation is applied, the subscription is notified synchronously an ```javascript // Subscribe to the channel object -myObject.subscribe(({ object, message }) => { +rootObject.subscribe(({ object, message }) => { console.log("Update:", message?.operation.action, message?.operation?.mapSet?.key); }); // Perform a batch operation -await myObject.batch((ctx) => { +await rootObject.batch((ctx) => { ctx.set('foo', 'bar'); ctx.set('baz', 42); ctx.set('qux', 'hello'); @@ -269,3 +279,5 @@ await myObject.batch((ctx) => { // Update: map.set qux ``` + + diff --git a/src/pages/docs/liveobjects/concepts/instance.mdx b/src/pages/docs/liveobjects/concepts/instance.mdx index dac86a6610..68122576bc 100644 --- a/src/pages/docs/liveobjects/concepts/instance.mdx +++ b/src/pages/docs/liveobjects/concepts/instance.mdx @@ -27,11 +27,13 @@ meta_description: "Learn about Instance, a reference to a specific LiveObject in An `Instance` represents a specific object instance within the channel object. When you call methods on an `Instance`, they operate on that specific instance regardless of where it exists in the structure or whether it has been moved. + + ## Get an Instance @@ -40,31 +42,60 @@ Obtain an `Instance` from a `PathObject` using the `instance()` method: ```javascript // Get a PathObject for the channel object -const myObject = await channel.object.get(); +const rootObject = await channel.object.get(); + +// Get the specific Instance of a LiveCounter located at the 'visits' key +const visits = rootObject.get('visits').instance(); +console.log(visits?.id); // e.g. counter:abc123@1234567890 +console.log(visits?.value()); // e.g. 5 +``` + +```java +// Get a PathObject for the channel object +LiveMapPathObject rootObject = channel.object.get().join(); // Get the specific Instance of a LiveCounter located at the 'visits' key -const visits = myObject.get('visits').instance(); -console.log(counterInstance?.id); // e.g. counter:abc123@1234567890 -console.log(counterInstance?.value()); // e.g. 5 +Instance visitsInstance = rootObject.get("visits").instance(); // null only if nothing exists at the path +if (visitsInstance != null) { + LiveCounterInstance visits = visitsInstance.asLiveCounter(); // throws IllegalStateException on mismatch + System.out.println(visits.getId()); // e.g. "counter:abc123@1234567890" + System.out.println(visits.value()); // e.g. 5.0 +} ``` + The `instance()` method returns `undefined` if no object exists at that path. + + +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`). + Once you have an `Instance`, it references a specific object by its ID. This means operations target that exact object: ```javascript // Obtain an Instance for a LiveCounter -const visits = myObject.get('visits').instance(); +const visits = rootObject.get('visits').instance(); // Increment the specific LiveCounter instance await visits?.increment(5); ``` + +```java +// Obtain an Instance for a LiveCounter +Instance visitsInstance = rootObject.get("visits").instance(); + +// Increment the specific LiveCounter instance +if (visitsInstance != null) { + visitsInstance.asLiveCounter().increment(5).join(); +} +``` An `Instance` references a specific object by its ID rather than by location. + ## Type inference When using TypeScript, you can provide type parameters to `instance()` to get rich type inference: @@ -78,21 +109,21 @@ type Settings = { notifications: boolean; }; -type MyObject = { +type RootObject = { visits: LiveCounter; settings: LiveMap; }; -const myObject = await channel.object.get(); +const rootObject = await channel.object.get(); // TypeScript knows 'visits' is a LiveCounter -const visits: Instance | undefined = myObject.get('visits').instance(); +const visits: Instance | undefined = rootObject.get('visits').instance(); if (visits) { await visits.increment(1); // Type-safe } // TypeScript knows the shape of 'settings' -const settings: Instance> | undefined = myObject.get('settings').instance(); +const settings: Instance> | undefined = rootObject.get('settings').instance(); if (settings) { const theme = settings.get('theme'); console.log(theme?.value()); // Returns string | undefined @@ -101,21 +132,46 @@ if (settings) { See the [Typing documentation](/docs/liveobjects/typing) for more details on type safety. + + + +## Type inference + +An `Instance` wraps a value whose type is already known, so its `as*` casts **throw** an `IllegalStateException` when the type doesn't match. This is the opposite of the `PathObject` casts, which never throw. In exchange, reads on a typed instance never return `null`. Check `getType()` first when the type isn't known; `getType()` on an `Instance` is non-null. + + +```java +Instance visitsInstance = rootObject.get("visits").instance(); +if (visitsInstance != null && visitsInstance.getType() == ValueType.LIVE_COUNTER) { + visitsInstance.asLiveCounter().increment(1).join(); +} +``` + + +See the [Type inference documentation](/docs/liveobjects/typing?lang=java#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: + + + + + ```javascript -const myObject = await channel.object.get(); +const rootObject = await channel.object.get(); // Obtain an instance -const settings = myObject.get('settings').instance(); +const settings = rootObject.get('settings').instance(); console.log(settings?.id); // e.g. "map:abc123@1234567890" // Chain get() calls for deeper navigation @@ -124,22 +180,46 @@ console.log(settings?.id); // e.g. "map:abc123@1234567890" const color = settings?.get('theme')?.get('color'); console.log(color?.value()); // e.g. blue ``` + +```java +// Obtain an instance +Instance settingsInstance = rootObject.get("settings").instance(); +if (settingsInstance != null) { + LiveMapInstance settings = settingsInstance.asLiveMap(); + System.out.println(settings.getId()); // e.g. "map:abc123@1234567890" + + // Chain get() calls for deeper navigation + // get() may return null at any point if the accessed entry does not exist + Instance theme = settings.get("theme"); + if (theme != null && theme.getType() == ValueType.LIVE_MAP) { + Instance color = theme.asLiveMap().get("color"); + if (color != null) { + System.out.println(color.asString().value()); // e.g. "blue" + } + } +} +``` + The `id` getter returns the [object ID](/docs/liveobjects/concepts/objects#object-ids) of the instance, which can be used with the [REST API](/docs/liveobjects/rest-api-usage). + + +The `getId()` method returns the [object ID](/docs/liveobjects/concepts/objects?lang=java#object-ids) of the instance, which can be used with the [REST API](/docs/liveobjects/rest-api-usage). + ## Access data Use the `get(key)` method to access a value from a `LiveMap` instance. The returned `Instance` represents whatever exists at that entry. What you can do with that `Instance` depends on whether the entry contains a primitive value, a `LiveCounter`, or a nested `LiveMap`. -### Read values +### Read values When an entry contains a primitive value or `LiveCounter` stored directly in the `LiveMap`, use the `value()` method to get the current value: ```javascript // Get an instance -const user = myObject.get('user').instance(); +const user = rootObject.get('user').instance(); // Get the value of an entry containing a primitive const username = user?.get('username'); @@ -149,34 +229,86 @@ console.log('Username:', username?.value()); // e.g. "alice" const visits = user?.get('visits'); console.log(visits?.value()); // e.g. 5 ``` + +```java +// Get an instance +Instance userInstance = rootObject.get("user").instance(); +if (userInstance != null) { + LiveMapInstance user = userInstance.asLiveMap(); + + // Get the value of an entry containing a primitive + Instance username = user.get("username"); + if (username != null) { + System.out.println("Username: " + username.asString().value()); // e.g. "alice" + } + + // Get the value of the LiveCounter stored in 'visits' + Instance visits = user.get("visits"); + if (visits != null) { + System.out.println(visits.asLiveCounter().value()); // e.g. 5.0 + } +} +``` + If the entry doesn't exist, `get()` returns undefined: + + +If the entry doesn't exist, `get()` returns `null`: + ```javascript -const user = myObject.get('user').instance(); +const user = rootObject.get('user').instance(); if (user) { // Get an entry that doesn't exist on the instance console.log(user.get('nonexistent')); // undefined } ``` + +```java +Instance userInstance = rootObject.get("user").instance(); +if (userInstance != null) { + // Get an entry that doesn't exist on the instance + System.out.println(userInstance.asLiveMap().get("nonexistent")); // null +} +``` + If the entry exists, but does not contain a primitive or a `LiveCounter` instance, `value()` returns `undefined`: ```javascript -const myObject = await channel.object.get(); +const rootObject = await channel.object.get(); // Set an entry that contains a nested LiveMap -await myObject.set('settings', LiveMap.create({ theme: 'dark' })); +await rootObject.set('settings', LiveMap.create({ theme: 'dark' })); // Calling value() on a LiveMap returns undefined -const settings = myObject.get('settings').instance(); +const settings = rootObject.get('settings').instance(); console.log(settings?.value()); // undefined - it's a LiveMap, not a primitive or LiveCounter ``` + + + +In Java, a wrong-typed call cannot happen: the method simply doesn't exist on the typed instance, and casting to the wrong type throws an `IllegalStateException`. Check [`getType()`](/docs/liveobjects/typing?lang=java#get-type) when the type isn't known. See the [Type inference documentation](/docs/liveobjects/typing?lang=java#type-inference) for the full cast contract: + + +```java +Instance settingsInstance = rootObject.get("settings").instance(); // a LiveMap +if (settingsInstance != null) { + System.out.println(settingsInstance.getType()); // LIVE_MAP + + // Maps have no value() and counters have no entries()/size() - those methods + // simply don't exist on the wrong type. Casting to the wrong type throws: + settingsInstance.asLiveCounter(); // throws IllegalStateException +} +``` + + ### Enumerate collections @@ -184,7 +316,7 @@ For `LiveMap` instances, you can iterate over the entries, keys, and values: ```javascript -const settings = myObject.get('settings').instance(); +const settings = rootObject.get('settings').instance(); if (settings) { // Iterate over key-value pairs. @@ -204,13 +336,33 @@ if (settings) { } } ``` + +```java +Instance settingsInstance = rootObject.get("settings").instance(); +if (settingsInstance != null) { + LiveMapInstance settings = settingsInstance.asLiveMap(); + + // Iterate over key-value pairs. + // Each key is a string, and the value is an Instance for the entry. + for (Map.Entry entry : settings.entries()) { + System.out.println(entry.getKey() + ": " + entry.getValue().compactJson()); + } + + // Iterate over keys only + for (String key : settings.keys()) { System.out.println("Key: " + key); } + + // Iterate over values + for (Instance value : settings.values()) { System.out.println("Value: " + value.compactJson()); } +} +``` + Collection methods return empty iterators if the instance is not a `LiveMap`: ```javascript -const visits = myObject.get('visits').instance(); // This is a LiveCounter, not a LiveMap +const visits = rootObject.get('visits').instance(); // This is a LiveCounter, not a LiveMap if (visits) { for (const [key, value] of visits.entries()) { @@ -219,6 +371,7 @@ if (visits) { } ``` + ### Get the size of a collection @@ -226,29 +379,46 @@ Use the `size()` method to get the number of entries in a `LiveMap`: ```javascript -const settings = myObject.get('settings').instance(); +const settings = rootObject.get('settings').instance(); // Get the number of entries console.log(settings?.size()); ``` + +```java +Instance settingsInstance = rootObject.get("settings").instance(); +if (settingsInstance != null) { + // Get the number of entries - non-null Long on an Instance + System.out.println(settingsInstance.asLiveMap().size()); +} +``` + The `size()` method returns `undefined` if the instance is not a `LiveMap`: ```javascript -const visits = myObject.get('visits').instance(); // This is a LiveCounter, not a LiveMap +const visits = rootObject.get('visits').instance(); // This is a LiveCounter, not a LiveMap console.log(visits?.size()); // undefined ``` + -### Get a compact object + +### Get a compact object + +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 `compact()` method returns a JavaScript object representation of the instance: ```javascript -const settingsInstance = myObject.get('settings').instance(); +const settingsInstance = rootObject.get('settings').instance(); if (settingsInstance) { const compact = settingsInstance.compact(); @@ -259,7 +429,7 @@ if (settingsInstance) { // } } -const counterInstance = myObject.get('visits').instance(); +const counterInstance = rootObject.get('visits').instance(); if (counterInstance) { console.log(counterInstance.compact()); // Just the number, e.g., 42 } @@ -277,7 +447,7 @@ If the instance contains cyclic references, these are preserved in the returned // ├─ profile -> references user (creates cycle) // └─ name: "Alice" -const userInstance = myObject.get('user').instance(); +const userInstance = rootObject.get('user').instance(); if (userInstance) { // When you call compact(), cyclic references are preserved as actual JS references const compacted = userInstance.compact(); @@ -293,8 +463,14 @@ if (userInstance) { It is possible for the value returned from `compact()` to contain cyclic references, so it is not safe to serialize this value with `JSON.stringify()`. + + 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: + ```javascript @@ -303,7 +479,7 @@ Use the `compactJson()` method to obtain a value that can be safely passed to `J // ├─ profile -> references user (creates cycle) // └─ name: "Alice" -const userInstance = myObject.get('user').instance(); +const userInstance = rootObject.get('user').instance(); if (userInstance) { // compactJson() breaks cycles, making it safe to serialize const compactJson = userInstance.compactJson(); @@ -318,24 +494,52 @@ if (userInstance) { // } } ``` + +```java +// Example: Using the same cyclic structure from before +// user (LiveMap) +// ├─ profile -> references user (creates cycle) +// └─ name: "Alice" + +Instance userInstance = rootObject.get("user").instance(); +if (userInstance != null) { + // compactJson() breaks cycles, making it safe to serialize. + // The cycle is broken via an objectId reference. + System.out.println(userInstance.compactJson()); // non-null on an Instance + // {"profile":{"objectId":"map:abc123@1234567890"},"name":"Alice"} +} +``` Binary data in the channel object are serialized as base64-encoded strings by `compactJson()`: ```javascript -const mapInstance = myObject.instance(); +const rootInstance = rootObject.instance(); -if (mapInstance) { +if (rootInstance) { // Store binary data in the LiveMap const binaryData = new TextEncoder().encode("world"); - await mapInstance.set('hello', binaryData); + await rootInstance.set('hello', binaryData); // compactJson() converts binary data to base64 strings - const compactJson = mapInstance.compactJson(); + const compactJson = rootInstance.compactJson(); console.log(compactJson.hello); // "d29ybGQ=" (base64 encoded) } ``` + +```java +// The root LiveMap always exists, so instance() is non-null here +LiveMapInstance rootInstance = rootObject.instance().asLiveMap(); + +// Store binary data in the LiveMap +rootInstance.set("hello", LiveMapValue.of("world".getBytes(StandardCharsets.UTF_8))).join(); + +// compactJson() converts binary data to base64 strings; +// LiveMapInstance narrows the return type to JsonObject +JsonObject json = rootInstance.compactJson(); +System.out.println(json.get("hello").getAsString()); // "d29ybGQ=" +``` ## Update data @@ -350,28 +554,49 @@ When you call a method on an `Instance`, it directly updates that specific insta ```javascript // Update a LiveMap instance -const settings = myObject.get('settings').instance(); +const settings = rootObject.get('settings').instance(); await settings?.set('theme', 'dark'); await settings?.remove('oldSetting'); // Update a LiveCounter instance -const visits = myObject.get('visits').instance(); +const visits = rootObject.get('visits').instance(); await visits?.increment(5); await visits?.decrement(2); ``` + +```java +// Update a LiveMap instance +Instance settingsInstance = rootObject.get("settings").instance(); +if (settingsInstance != null) { + LiveMapInstance settings = settingsInstance.asLiveMap(); + settings.set("theme", LiveMapValue.of("dark")).join(); + settings.remove("oldSetting").join(); +} + +// Update a LiveCounter instance +Instance visitsInstance = rootObject.get("visits").instance(); +if (visitsInstance != null) { + LiveCounterInstance visits = visitsInstance.asLiveCounter(); + visits.increment(5).join(); + visits.decrement(2).join(); +} +``` + + + ### Batch multiple updates The `batch(callback)` method groups multiple mutations into a single message. The `ctx` parameter is the batch context for the specific instance: ```javascript -const settings = myObject.get('settings').instance(); +const settings = rootObject.get('settings').instance(); await settings?.batch((ctx) => { ctx.set('theme', 'dark'); ctx.get('preferences')?.set('language', 'en'); @@ -381,6 +606,7 @@ await settings?.batch((ctx) => { See the [Batch operations documentation](/docs/liveobjects/batch) for more details on batching. + ## Subscribe to changes @@ -388,7 +614,7 @@ Use the `subscribe()` method to be notified when the instance is updated: ```javascript -const visits = myObject.get('visits').instance(); +const visits = rootObject.get('visits').instance(); if (visits) { const { unsubscribe } = visits.subscribe(() => { @@ -399,13 +625,25 @@ if (visits) { unsubscribe(); } ``` + +```java +Instance visitsInstance = rootObject.get("visits").instance(); +if (visitsInstance != null) { + Subscription subscription = visitsInstance.asLiveCounter().subscribe(event -> + System.out.println("Visits updated")); + + // Later, stop listening to changes + subscription.unsubscribe(); +} +``` + Alternatively, use the `subscribeIterator()` method for an async iterator syntax: ```javascript -const visits = myObject.get('visits').instance(); +const visits = rootObject.get('visits').instance(); if (visits) { for await (const _ of visits.subscribeIterator()) { @@ -418,18 +656,26 @@ if (visits) { } ``` + + + + + + `Instance` subscriptions observe a specific object instance rather than a location. If the instance is moved to a different location within the channel object, the subscription continues to observe the same instance. -When an object instance is deleted after becoming [unreachable](/docs/liveobjects/concepts/objects?lang=javascript#reachability), subscriptions to that instance are automatically unsubscribed after one final notification: +When an object instance is deleted after becoming [unreachable](/docs/liveobjects/concepts/objects#reachability), subscriptions to that instance are automatically unsubscribed after one final notification: ```javascript -const visits = myObject.get('visits').instance(); +const visits = rootObject.get('visits').instance(); if (visits) { visits.subscribe(({ object, message }) => { @@ -441,7 +687,24 @@ if (visits) { // Make the LiveCounter stored in visits unreachable. // It will eventually be deleted. - await myObject.remove('visits'); + await rootObject.remove('visits'); +} +``` + +```java +Instance visitsInstance = rootObject.get("visits").instance(); +if (visitsInstance != null) { + visitsInstance.asLiveCounter().subscribe(event -> { + ObjectMessage message = event.getMessage(); + if (message != null && message.getOperation().getAction() == ObjectOperationAction.OBJECT_DELETE) { + System.out.println("visits counter was deleted"); + // This listener is automatically unsubscribed after this event + } + }); + + // Make the LiveCounter stored in visits unreachable. + // It will eventually be deleted. + rootObject.remove("visits").join(); } ``` @@ -450,12 +713,18 @@ if (visits) { The subscription receives an argument with information about the update: + - The `object` field contains an `Instance` representing the same object instance you called `subscribe()` on - 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. + + +- `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. + ```javascript -const visits = myObject.get('visits').instance(); +const visits = rootObject.get('visits').instance(); if (visits) { visits.subscribe(({ object, message }) => { @@ -466,6 +735,23 @@ if (visits) { }); } ``` + +```java +Instance visitsInstance = rootObject.get("visits").instance(); +if (visitsInstance != null) { + LiveCounterInstance visits = visitsInstance.asLiveCounter(); + visits.subscribe(event -> { + LiveCounterInstance object = event.getObject().asLiveCounter(); + System.out.println("New value: " + object.value()); + ObjectMessage message = event.getMessage(); + if (message != null) { + System.out.println("Updated by: " + message.getClientId()); + System.out.println("Operation: " + message.getOperation().getAction()); // e.g. COUNTER_INC + } + System.out.println(object.getId().equals(visits.getId())); // true - same instance + }); +} +``` ### Updates to nested objects @@ -474,7 +760,7 @@ Instance subscriptions only observe changes to the specific instance that is sub ```javascript -const settings = myObject.get('settings').instance(); +const settings = rootObject.get('settings').instance(); if (settings) { // Subscribe to the settings LiveMap instance @@ -489,6 +775,25 @@ if (settings) { await settings.get('preferences')?.set('language', 'en'); } ``` + +```java +Instance settingsInstance = rootObject.get("settings").instance(); +if (settingsInstance != null) { + LiveMapInstance settings = settingsInstance.asLiveMap(); + + // Subscribe to the settings LiveMap instance + settings.subscribe(event -> System.out.println("Settings LiveMap updated")); + + // This triggers the subscription (direct change to settings) + settings.set("theme", LiveMapValue.of("dark")).join(); + + // This does NOT trigger the subscription (change to nested object) + Instance preferences = settings.get("preferences"); + if (preferences != null) { + preferences.asLiveMap().set("language", LiveMapValue.of("en")).join(); + } +} +``` - + + + + diff --git a/src/pages/docs/liveobjects/counter.mdx b/src/pages/docs/liveobjects/counter.mdx index aaa15da41f..a340a5de63 100644 --- a/src/pages/docs/liveobjects/counter.mdx +++ b/src/pages/docs/liveobjects/counter.mdx @@ -25,10 +25,16 @@ 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). + + + + ## Create a counter Create a `LiveCounter` using the `LiveCounter.create()` static method and assign it to a path: @@ -38,13 +44,35 @@ Create a `LiveCounter` using the `LiveCounter.create()` static method and assign import { LiveCounter } from 'ably/liveobjects'; // Create a counter with initial value 0 -await myObject.set('visits', LiveCounter.create(0)); +await rootObject.set('visits', LiveCounter.create(0)); + +// Create a counter with initial value 100 +await rootObject.set('score', LiveCounter.create(100)); + +// Create a counter without specifying initial value (defaults to 0) +await rootObject.set('clicks', LiveCounter.create()); +``` + +```java +// rootObject obtained from channel.object.get() + +// Create a counter with initial value 0 +LiveCounter visits = LiveCounter.create(0); + +// Assign the counter to the 'visits' key on the channel object +rootObject.set("visits", LiveMapValue.of(visits)).join(); // Create a counter with initial value 100 -await myObject.set('score', LiveCounter.create(100)); +LiveCounter score = LiveCounter.create(100); + +// Assign the counter to the 'score' key on the channel object +rootObject.set("score", LiveMapValue.of(score)).join(); // Create a counter without specifying initial value (defaults to 0) -await myObject.set('clicks', LiveCounter.create()); +LiveCounter clicks = LiveCounter.create(); + +// Assign the counter to the 'clicks' key on the channel object +rootObject.set("clicks", LiveMapValue.of(clicks)).join(); ``` @@ -55,51 +83,88 @@ await myObject.set('clicks', LiveCounter.create()); const counterValue = LiveCounter.create(0); // Each assignment creates a different object -await myObject.set('counter1', counterValue); -await myObject.set('counter2', counterValue); +await rootObject.set('counter1', counterValue); +await rootObject.set('counter2', counterValue); // counter1 and counter2 are different objects with different IDs -const id1 = myObject.get('counter1').instance()?.id(); -const id2 = myObject.get('counter2').instance()?.id(); +const id1 = rootObject.get('counter1').instance()?.id; +const id2 = rootObject.get('counter2').instance()?.id; console.log(id1 === id2); // false ``` + +```java +LiveCounter counterValue = LiveCounter.create(0); + +// Each assignment creates a different object +rootObject.set("counter1", LiveMapValue.of(counterValue)).join(); +rootObject.set("counter2", LiveMapValue.of(counterValue)).join(); + +// counter1 and counter2 are different objects with different IDs +Instance counter1Instance = rootObject.get("counter1").instance(); +Instance counter2Instance = rootObject.get("counter2").instance(); +if (counter1Instance != null && counter2Instance != null) { + System.out.println(counter1Instance.asLiveCounter().getId() + .equals(counter2Instance.asLiveCounter().getId())); // false +} +``` ## Get counter value -Access a `LiveCounter` through a [PathObject](/docs/liveobjects/concepts/path-object) for path-based operations, or obtain a specific [Instance](/docs/liveobjects/concepts/instance) to work with the underlying object directly. Use the `value()` method to get the value of the `LiveCounter`: +Access a `LiveCounter` through a [PathObject](/docs/liveobjects/concepts/path-object) for path-based operations, or obtain a specific [Instance](/docs/liveobjects/concepts/instance) to work with the underlying object directly. Use the `value()` method to get the value of the `LiveCounter`:Cast with `asLiveCounter()` and use the `value()` method to get the value of the `LiveCounter`: ```javascript -const myObject = await channel.object.get(); +const rootObject = await channel.object.get(); // PathObject access: path-based operations that resolve at runtime -const visits = myObject.get('visits'); +const visits = rootObject.get('visits'); console.log(visits.value()); // e.g. 5 // Instance access: reference to a specific counter object -const visitsInstance = myObject.get('visits').instance(); -console.log(visitsInstance?.value()); e.g. 5 +const visitsInstance = rootObject.get('visits').instance(); +console.log(visitsInstance?.value()); // e.g. 5 +``` + +```java +LiveMapPathObject rootObject = channel.object.get().join(); + +// PathObject access - Double, or null when the path doesn't resolve to a counter +System.out.println(rootObject.get("visits").asLiveCounter().value()); // e.g. 5.0 + +// Instance access - value() returns a non-null Double +Instance visitsInstance = rootObject.get("visits").instance(); +if (visitsInstance != null) { + System.out.println(visitsInstance.asLiveCounter().value()); // e.g. 5.0 +} ``` + + + + + ## Get compact object + Get a numeric representation of the counter using the `compact()` or `compactJson()` methods. These methods return the same result as `value()`: ```javascript // Get a PathObject to a LiveCounter stored in 'visits' -const visits = myObject.get('visits'); +const visits = rootObject.get('visits'); console.log(visits.compact()); // e.g. 5 // Get the Instance of a LiveCounter stored in 'visits' -const visitsInstance = myObject.get('visits').instance(); +const visitsInstance = rootObject.get('visits').instance(); console.log(visitsInstance?.compact()); // e.g. 5 ``` @@ -109,14 +174,47 @@ When calling these methods on a `LiveMap`, nested `LiveCounter` objects are incl ```javascript // Get a PathObject to a LiveMap stored in 'stats' -const stats = myObject.get('stats'); +const stats = rootObject.get('stats'); console.log(stats.compact()); // e.g. { visits: 5 } // Get the Instance of a LiveMap stored in 'stats' -const visitsInstance = myObject.get('stats').instance(); -console.log(visitsInstance?.compact()); // e.g. 5 +const statsInstance = rootObject.get('stats').instance(); +console.log(statsInstance?.compact()); // e.g. { visits: 5 } ``` + + + +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()`: + + +```java +// Get a PathObject to a LiveCounter stored in 'visits' +System.out.println(rootObject.get("visits").compactJson()); // JsonElement, null if unresolved; e.g. 5.0 (JsonPrimitive) + +// Get the Instance of a LiveCounter stored in 'visits' +Instance visitsInstance = rootObject.get("visits").instance(); +if (visitsInstance != null) { + System.out.println(visitsInstance.asLiveCounter().compactJson()); // non-null JsonPrimitive, e.g. 5.0 +} +``` + + +When calling `compactJson()` on a `LiveMap`, nested `LiveCounter` objects are included as a number: + + +```java +// Get a PathObject to a LiveMap stored in 'stats' +System.out.println(rootObject.get("stats").compactJson()); // JsonElement, null if unresolved; e.g. {"visits":5.0} + +// Get the Instance of a LiveMap stored in 'stats' +Instance statsInstance = rootObject.get("stats").instance(); +if (statsInstance != null) { + System.out.println(statsInstance.asLiveMap().compactJson()); // non-null JsonObject, e.g. {"visits":5.0} +} +``` + +