diff --git a/README.md b/README.md index f69d00fd6..b60ff435d 100644 --- a/README.md +++ b/README.md @@ -584,13 +584,15 @@ const client = new Ably.Rest({ The Push plugin is developed as part of the Ably client library, so it is available for the same versions as the Ably client library itself. It also means that it follows the same semantic versioning rules as they were defined for [the Ably client library](#for-browsers). For example, to lock into a major or minor version of the Push plugin, you can specify a specific version number such as https://cdn.ably.com/lib/push.umd.min-2.js for all v2._ versions, or https://cdn.ably.com/lib/push.umd.min-2.4.js for all v2.4._ versions, or you can lock into a single release with https://cdn.ably.com/lib/push.umd.min-2.4.0.js. Note you can load the non-minified version by omitting `.min` from the URL such as https://cdn.ably.com/lib/push.umd-2.js. -For more information on publishing push notifcations over Ably, see the [Ably push documentation](https://ably.com/docs/push). +For more information on publishing push notifications over Ably, see the [Ably push documentation](https://ably.com/docs/push). -### Live Objects functionality +### LiveObjects -Live Objects functionality is supported for Realtime clients via the LiveObjects plugin. In order to use Live Objects, you must pass in the plugin via client options. +#### Using the plugin -```javascript +LiveObjects functionality is supported for Realtime clients via the LiveObjects plugin. In order to use LiveObjects, you must pass in the plugin via client options. + +```typescript import * as Ably from 'ably'; import LiveObjects from 'ably/liveobjects'; @@ -610,7 +612,7 @@ Alternatively, you can load the LiveObjects plugin directly in your HTML using ` When loaded this way, the LiveObjects plugin will be available on the global object via the `AblyLiveObjectsPlugin` property, so you will need to pass it to the Ably instance as follows: -```javascript +```typescript const client = new Ably.Realtime({ ...options, plugins: { LiveObjects: AblyLiveObjectsPlugin }, @@ -619,7 +621,287 @@ const client = new Ably.Realtime({ The LiveObjects plugin is developed as part of the Ably client library, so it is available for the same versions as the Ably client library itself. It also means that it follows the same semantic versioning rules as they were defined for [the Ably client library](#for-browsers). For example, to lock into a major or minor version of the LiveObjects plugin, you can specify a specific version number such as https://cdn.ably.com/lib/liveobjects.umd.min-2.js for all v2._ versions, or https://cdn.ably.com/lib/liveobjects.umd.min-2.4.js for all v2.4._ versions, or you can lock into a single release with https://cdn.ably.com/lib/liveobjects.umd.min-2.4.0.js. Note you can load the non-minified version by omitting `.min` from the URL such as https://cdn.ably.com/lib/liveobjects.umd-2.js. -For more information about Live Objects product, see the [Ably Live Objects documentation](https://ably.com/docs/products/liveobjects). +For more information about the LiveObjects product, see the [Ably LiveObjects documentation](https://ably.com/docs/products/liveobjects). + +#### State Channel Modes + +To use the LiveObjects feature, clients must attach to a channel with the correct channel mode: + +- `state_subscribe` - required to retrieve state of Live Objects for a channel +- `state_publish` - required to create new and modify existing Live Objects on a channel + +```typescript +const client = new Ably.Realtime({ + // authentication options + ...options, + plugins: { LiveObjects }, +}); +const channelOptions = { modes: ['state_subscribe', 'state_publish'] }; +const channel = client.channels.get('my_live_objects_channel', channelOptions); +const liveObjects = channel.liveObjects; +``` + +The authentication token must include corresponding capabilities for the client to interact with LiveObjects. + +#### Getting the Root Object + +The root object represents the top-level entry point for LiveObjects state within a channel. It gives access to all other nested Live Objects. + +```typescript +const root = await liveObjects.getRoot(); +``` + +The root object is a `LiveMap` instance and serves as the starting point for storing and organizing LiveObjects state. + +#### Live Object Types + +LiveObjects currently supports two primary data structures; `LiveMap` and `LiveCounter`. + +`LiveMap` - A key/value map data structure, similar to a JavaScript `Map`, where all changes are synchronized across clients in realtime. It allows you to store primitive values and other Live Objects, enabling a composable state. + +You can use `LiveMap` as follows: + +```typescript +// root object is a LiveMap +const root = await liveObjects.getRoot(); + +// you can read values for a key with .get +root.get('foo'); +root.get('bar'); + +// get a number of key/value pairs in a map with .size +root.size(); + +// set keys on a map with .set +// different data types are supported +await root.set('foo', 'Alice'); +await root.set('bar', 1); +await root.set('baz', true); +await root.set('qux', new Uint8Array([21, 31])); +// as well as other live objects +const counter = await liveObjects.createCounter(); +await root.set('quux', counter); + +// and you can remove keys with .remove +await root.remove('name'); +``` + +`LiveCounter` - A counter that can be incremented or decremented and is synchronized across clients in realtime + +You can use `LiveCounter` as follows: + +```typescript +const counter = await liveObjects.createCounter(); + +// you can get current value of a counter with .value +counter.value(); + +// and change its value with .increment or .decrement +await counter.increment(5); +await counter.decrement(2); +``` + +#### Subscribing to Updates + +Subscribing to updates on Live Objects allows you to receive changes made by other clients in realtime. Since multiple clients may modify the same Live Objects state, subscribing ensures that your application reacts to external updates as soon as they are received. + +Additionally, mutation methods such as `LiveMap.set`, `LiveCounter.increment`, and `LiveCounter.decrement` do not directly edit the current state of the object locally. Instead, they send the intended operation to the Ably system, and the change is applied to the local object only when the corresponding realtime operation is echoed back to the client. This means that the state you retrieve immediately after a mutation may not reflect the latest updates yet. + +You can subscribe to updates on all Live Objects using subscription listeners as follows: + +```typescript +const root = await liveObjects.getRoot(); + +// subscribe to updates on a LiveMap +root.subscribe((update: LiveMapUpdate) => { + console.log('LiveMap "name" key:', root.get('name')); // can read the current value for a key in a map inside this callback + console.log('LiveMap update details:', update); // and can get update details from the provided update object +}); + +// subscribe to updates on a LiveCounter +const counter = await liveObjects.createCounter(); +counter.subscribe((update: LiveCounterUpdate) => { + console.log('LiveCounter new value:', counter.value()); // can read the current value of the counter inside this callback + console.log('LiveCounter update details:', update); // and can get update details from the provided update object +}); + +// perform operations on LiveMap and LiveCounter +await root.set('name', 'Alice'); +// LiveMap "name" key: Alice +// LiveMap update details: { update: { name: 'updated' } } + +await root.remove('name'); +// LiveMap "name" key: undefined +// LiveMap update details: { update: { name: 'removed' } } + +await counter.increment(5); +// LiveCounter new value: 5 +// LiveCounter update details: { update: { inc: 5 } } + +await counter.decrement(2); +// LiveCounter new value: 3 +// LiveCounter update details: { update: { inc: -2 } } +``` + +You can deregister subscription listeners as follows: + +```typescript +// use dedicated unsubscribe function from the .subscribe call +const { unsubscribe } = root.subscribe(() => {}); +unsubscribe(); + +// call .unsubscribe with a listener reference +const listener = () => {}; +root.subscribe(listener); +root.unsubscribe(listener); + +// deregister all listeners using .unsubscribeAll +root.unsubscribeAll(); +``` + +#### Creating New Objects + +New `LiveMap` and `LiveCounter` instances can be created as follows: + +```typescript +const counter = await liveObjects.createCounter(123); // with optional initial counter value +const map = await liveObjects.createMap({ key: 'value' }); // with optional initial map entries +``` + +To persist them within the LiveObjects state, they must be assigned to a parent `LiveMap` that is connected to the root object through the object hierarchy: + +```typescript +const root = await liveObjects.getRoot(); + +const counter = await liveObjects.createCounter(); +const map = await liveObjects.createMap({ counter }); +const outerMap = await liveObjects.createMap({ map }); + +await root.set('outerMap', outerMap); + +// resulting structure: +// root (LiveMap) +// └── outerMap (LiveMap) +// └── map (LiveMap) +// └── counter (LiveCounter) +``` + +#### Batch Operations + +Batching allows multiple operations to be grouped into a single message that is sent to the Ably service. This allows batched operations to be applied atomically together. + +Within a batch callback, the `BatchContext` instance provides wrapper objects around regular Live Objects with a synchronous API for storing changes in the batch context. + +```typescript +await liveObjects.batch((ctx) => { + const root = ctx.getRoot(); + + root.set('foo', 'bar'); + root.set('baz', 42); + + const counter = root.get('counter'); + counter.increment(5); +}); + +console.log(root.get('foo')); // output: 'bar' +console.log(root.get('baz')); // output: 42 +console.log(root.get('counter').value()); // output: 5 +``` + +#### Lifecycle Events + +Live Objects emit lifecycle events to signal critical state changes, such as synchronization progress and object deletions. + +**Synchronization Events** - the `syncing` and `synced` events notify when the LiveObjects state is being synchronized with the Ably service. These events can be useful for displaying loading indicators, preventing user edits during synchronization, or triggering application logic when the data was loaded for the first time. + +```typescript +liveObjects.on('syncing', () => { + console.log('LiveObjects state is syncing...'); + // Example: Show a loading indicator +}); + +liveObjects.on('synced', () => { + console.log('LiveObjects state has been synced.'); + // Example: Hide loading indicator +}); +``` + +**Object Deletion Events** - objects that have been orphaned for a long period (i.e., not connected to the state tree graph by being set as a key in a map accessible from the root map object) will eventually be deleted. Once a Live Object is deleted, it can no longer be interacted with. You should avoid accessing its data or trying to update its value and you should remove all references to the deleted object in your application. + +```typescript +const root = await liveObjects.getRoot(); +const counter = root.get('counter'); + +counter.on('deleted', () => { + console.log('Live Object has been deleted.'); + // Example: Remove references to the object from the application +}); +``` + +To unsubscribe from lifecycle events: + +```typescript +// same API for channel.liveObjects and LiveObject instances +// use dedicated off function from the .on call +const { off } = liveObjects.on('synced', () => {}); +off(); + +// call .off with an event name and a listener reference +const listener = () => {}; +liveObjects.on('synced', listener); +liveObjects.off('synced', listener); + +// deregister all listeners using .offAll +liveObjects.offAll(); +``` + +#### Typing LiveObjects + +You can provide your own TypeScript typings for LiveObjects by providing a globally defined `LiveObjectsTypes` interface. + +```typescript +// file: ably.config.d.ts +import { LiveCounter, LiveMap } from 'ably'; + +type MyCustomRoot = { + map: LiveMap<{ + foo: string; + counter: LiveCounter; + }>; +}; + +declare global { + export interface LiveObjectsTypes { + root: MyCustomRoot; + } +} +``` + +This will enable code completion and editor hints when interacting with the LiveObjects API: + +```typescript +const root = await liveObjects.getRoot(); // uses types defined by global LiveObjectsTypes interface by default + +const map = root.get('map'); // LiveMap<{ foo: string; counter: LiveCounter }> +map.set('foo', 1); // TypeError +map.get('counter').value(); // autocompletion for counter method names +``` + +You can also provide typings for the LiveObjects state tree when calling the `liveObjects.getRoot` method, allowing you to have different state typings for different channels: + +```typescript +type ReactionsRoot = { + hearts: LiveCounter; + likes: LiveCounter; +}; + +type PollsRoot = { + currentPoll: LiveMap; +}; + +const reactionsRoot = await reactionsChannel.liveObjects.getRoot(); +const pollsRoot = await pollsChannel.liveObjects.getRoot(); +``` ## Delta Plugin diff --git a/ably.d.ts b/ably.d.ts index 4ed3c0811..99746e662 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -2056,11 +2056,11 @@ export declare interface PushChannel { */ declare namespace LiveObjectsEvents { /** - * The local Live Objects state is currently being synchronized with the Ably service. + * The local LiveObjects state is currently being synchronized with the Ably service. */ type SYNCING = 'syncing'; /** - * The local Live Objects state has been synchronized with the Ably service. + * The local LiveObjects state has been synchronized with the Ably service. */ type SYNCED = 'synced'; } @@ -2075,7 +2075,7 @@ export type LiveObjectsEvent = LiveObjectsEvents.SYNCED | LiveObjectsEvents.SYNC */ declare namespace LiveObjectLifecycleEvents { /** - * Indicates that the object has been deleted from the Live Objects pool and should no longer be interacted with. + * Indicates that the object has been deleted from the LiveObjects pool and should no longer be interacted with. */ type DELETED = 'deleted'; }