diff --git a/src/data/languages/languageData.ts b/src/data/languages/languageData.ts index afb821eb72..b818b334dc 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..61aa899377 100644 --- a/src/pages/docs/api/realtime-sdk/channels.mdx +++ b/src/pages/docs/api/realtime-sdk/channels.mdx @@ -114,13 +114,13 @@ 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. +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..2d9e201bac 100644 --- a/src/pages/docs/liveobjects/concepts/instance.mdx +++ b/src/pages/docs/liveobjects/concepts/instance.mdx @@ -27,11 +27,17 @@ 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. + + + + +In the Java SDK the base `Instance` type is type-agnostic; capabilities (`getId()`, `get()`, `value()`, `set()`, `subscribe()`) live on the typed instances (`LiveMapInstance`, `LiveCounterInstance` and the primitive instances), reached via throwing `as*` casts. Primitive instances (for example `StringInstance`, `NumberInstance`) are read-only and anonymous: they expose only `value()`, with no `getId()`, no mutation methods and no `subscribe()`. + ## Get an Instance @@ -40,31 +46,53 @@ 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 = myObject.get('visits').instance(); -console.log(counterInstance?.id); // e.g. counter:abc123@1234567890 -console.log(counterInstance?.value()); // e.g. 5 +const visits = rootObject.get('visits').instance(); +console.log(visits?.id); // e.g. counter:abc123@1234567890 +console.log(visits?.value()); // e.g. 5 +``` + +```java +LiveMapPathObject rootObject = channel.object.get().join(); + +Instance instance = rootObject.get("visits").instance(); // null if no LiveMap or LiveCounter at the path +if (instance != null) { + LiveCounterInstance visits = instance.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 `undefined` if no object exists at that path.The `instance()` method returns `null` if the path doesn't resolve, or if the entry does not contain a `LiveMap` or `LiveCounter` object, because primitive values have no object ID. Primitive instances are instead obtained by navigating from a `LiveMapInstance` with `get(key)`. 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 visits = rootObject.get("visits").instance(); + +// Increment the specific LiveCounter instance +if (visits != null) { + visits.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 +106,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 +129,46 @@ if (settings) { See the [Typing documentation](/docs/liveobjects/typing) for more details on type safety. + + + +## Typed views + +An `Instance` wraps an already-resolved value of known type, so its `as*` casts **throw** an `IllegalStateException` on mismatch, the opposite contract to the never-throwing `PathObject` casts. In exchange, reads on a typed instance are non-null. Check `getType()` first when the type isn't known; `getType()` on an `Instance` is non-null. + + +```java +Instance instance = rootObject.get("visits").instance(); +if (instance != null && instance.getType() == ValueType.LIVE_COUNTER) { + instance.asLiveCounter().increment(1).join(); +} +``` + + +See the [Typed views documentation](/docs/liveobjects/typing?lang=java#typed-views) 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,9 +177,27 @@ console.log(settings?.id); // e.g. "map:abc123@1234567890" const color = settings?.get('theme')?.get('color'); console.log(color?.value()); // e.g. blue ``` + +```java +Instance instance = rootObject.get("settings").instance(); +if (instance != null) { + LiveMapInstance settings = instance.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 `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 @@ -139,7 +210,7 @@ When an entry contains a primitive value or `LiveCounter` stored directly in the ```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 +220,81 @@ 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 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 user = rootObject.get("user").instance(); +if (user != null) { + // Get an entry that doesn't exist on the instance + System.out.println(user.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 ``` + + + +Where the JavaScript `Instance` degrades gracefully (an `undefined` value, empty iterators, an `undefined` size), Java prevents the call at compile time or fails fast in the cast. Check `getType()` when the type isn't known: + + +```java +Instance settings = rootObject.get("settings").instance(); // a LiveMap +if (settings != null) { + System.out.println(settings.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 view throws: + settings.asLiveCounter(); // throws IllegalStateException +} +``` + + ### Enumerate collections @@ -184,7 +302,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 +322,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 +357,7 @@ if (visits) { } ``` + ### Get the size of a collection @@ -226,29 +365,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 settings = rootObject.get("settings").instance(); +if (settings != null) { + // Get the number of entries - non-null Long on an Instance + System.out.println(settings.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 + +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 + +### 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 +415,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 +433,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 +449,9 @@ 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 value that can be safely passed to `JSON.stringify()`:Use the `compactJson()` method to obtain a JSON representation of the instance: ```javascript @@ -303,7 +460,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 +475,45 @@ if (userInstance) { // } } ``` + +```java +Instance userInstance = rootObject.get("user").instance(); +if (userInstance != null) { + 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 +528,54 @@ 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 +585,7 @@ await settings?.batch((ctx) => { See the [Batch operations documentation](/docs/liveobjects/batch) for more details on batching. + ## Subscribe to changes @@ -388,7 +593,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 +604,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,10 +635,18 @@ 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. @@ -429,7 +654,7 @@ When an object instance is deleted after becoming [unreachable](/docs/liveobject ```javascript -const visits = myObject.get('visits').instance(); +const visits = rootObject.get('visits').instance(); if (visits) { visits.subscribe(({ object, message }) => { @@ -441,7 +666,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 +692,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 an `Instance` representing the same object instance you called `subscribe()` on. It is the base `Instance` type, so cast it with the matching `as*` view. The cast is safe here because you subscribed on a typed instance, so the type is already known; on an unknown `Instance` the `as*` cast throws on mismatch. +- `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 +714,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 +739,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 +754,25 @@ if (settings) { await settings.get('preferences')?.set('language', 'en'); } ``` + +```java +Instance instance = rootObject.get("settings").instance(); +if (instance != null) { + LiveMapInstance settings = instance.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..15439ec9ff 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,32 @@ 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 +80,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 counter1 = rootObject.get("counter1").instance(); +Instance counter2 = rootObject.get("counter2").instance(); +if (counter1 != null && counter2 != null) { + System.out.println(counter1.asLiveCounter().getId() + .equals(counter2.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`:Use the `asLiveCounter()` typed view's `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 - non-null Double (the throwing cast catches mismatches earlier) +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 +171,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} +} +``` + +