diff --git a/examples/liveobjects-live-counter/javascript/README.md b/examples/liveobjects-live-counter/javascript/README.md index 9d4bff4c4b..98358b93c4 100644 --- a/examples/liveobjects-live-counter/javascript/README.md +++ b/examples/liveobjects-live-counter/javascript/README.md @@ -1,25 +1,27 @@ # Synchronize numeric values with LiveCounter -Enable clients to update and synchronize numerical values in an application in realtime. +Enable clients to update and synchronize numeric values in an application in realtime. -LiveCounter is a synchronized numerical counter that supports increment and decrement operations. It ensures that all updates are correctly applied and synchronized across users in realtime, preventing inconsistencies when multiple users modify the counter value simultaneously. +[`LiveCounter`](/docs/liveobjects/counter) is a synchronized numeric counter that supports increment and decrement operations. It ensures that all updates are correctly applied and synchronized across users in realtime, preventing inconsistencies when multiple users modify the counter value simultaneously. -LiveCounter is useful for tracking values that need to be updated dynamically, such as votes, reaction counts, live leaderboards, game stats, or other numeric data points. +`LiveCounter` is useful for tracking values that need to be updated dynamically, such as votes, reaction counts, live leaderboards, game stats, or other numeric data points. -LiveCounter is implemented using [Ably LiveObjects](/docs/liveobjects). LiveObjects, as a feature of [Ably Pub/Sub](/docs/channels), contains a set of purpose-built APIs that abstract away the complexities of managing shared state between clients in an application. It is built on top of Ably's core platform, and so it provides the same performance guarantees and scaling potential. +`LiveCounter` is a feature of [LiveObjects](/docs/liveobjects), which provides a serverless, durable, and scalable way to create, update, and subscribe to shared state across large numbers of connected clients at any scale. LiveObjects is built on [channels](/docs/channels) and provides the same [performance guarantees and scaling potential](/docs/platform/architecture). ## Resources -Use the following methods to interact with a LiveCounter in your application: +Use the following methods to interact with a `LiveCounter` in your application: -- [`objects.getRoot()`](/docs/liveobjects/concepts/objects#root-object): retrieves the root object that serves as the starting point for storing and organizing objects on a channel. -- [`objects.createCounter()`](/docs/liveobjects/counter#create): creates a new LiveCounter instance. -- [`liveCounter.value()`](/docs/liveobjects/counter#value): returns the current value of a counter. -- [`liveCounter.increment()`](/docs/liveobjects/counter#update): sends the operation message to the Ably system to increase the counter value. -- [`liveCounter.decrement()`](/docs/liveobjects/counter#update): sends the operation message to the Ably system to decrease the counter value. -- [`liveCounter.subscribe()`](/docs/liveobjects/counter#subscribe-data): subscribes to LiveCounter updates by registering a listener. +- [`channel.object.get()`](/docs/liveobjects/concepts/path-object): retrieve a [`PathObject`](/docs/liveobjects/concepts/path-object) reference to the [channel object](/docs/liveobjects/concepts/objects#channel-object). Use this `PathObject` to update and subscribe to data via: + - [`get(key)`](/docs/liveobjects/concepts/path-object#navigate): navigate to child paths within the `PathObject`, such as entries in a [`LiveMap`](/docs/liveobjects/map). + - [`set(key, value)`](/docs/liveobjects/concepts/path-object#update): assign data, such as a `LiveCounter` instance, to the specified key on a `LiveMap`. + - [`value()`](/docs/liveobjects/concepts/path-object#read-values): read the current value of the `LiveCounter` instance at the specified path. + - [`increment()`](/docs/liveobjects/counter#increment): send an [operation](/docs/liveobjects/concepts/operations) to increment the `LiveCounter` at the specified path. + - [`subscribe()`](/docs/liveobjects/concepts/path-object#subscribe): subscribe to updates at the specified path by registering a listener. + - [`batch()`](/docs/liveobjects/concepts/path-object#batch-multiple-updates): group multiple operations into a single message for atomic updates. +- [`LiveCounter.create()`](/docs/liveobjects/counter#create): create a new `LiveCounter` instance. -Find out more about [LiveCounter](/docs/liveobjects/counter). +Find out more about [PathObject](/docs/liveobjects/concepts/path-object) and [LiveCounter](/docs/liveobjects/counter). ## Getting started diff --git a/examples/liveobjects-live-counter/javascript/src/ably.config.d.ts b/examples/liveobjects-live-counter/javascript/src/ably.config.d.ts deleted file mode 100644 index ded7277a35..0000000000 --- a/examples/liveobjects-live-counter/javascript/src/ably.config.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { LiveCounter } from 'ably'; -import { Color } from './script'; - -declare global { - export interface AblyObjectsTypes { - root: { - [Color.red]: LiveCounter; - [Color.green]: LiveCounter; - [Color.blue]: LiveCounter; - }; - } -} diff --git a/examples/liveobjects-live-counter/javascript/src/script.ts b/examples/liveobjects-live-counter/javascript/src/script.ts index dddba6a0c9..1fc36052af 100644 --- a/examples/liveobjects-live-counter/javascript/src/script.ts +++ b/examples/liveobjects-live-counter/javascript/src/script.ts @@ -1,10 +1,16 @@ -import { DefaultRoot, LiveCounter, LiveMap, Realtime } from 'ably'; -import Objects from 'ably/objects'; +import { Realtime } from 'ably'; +import { LiveCounter, LiveMap, LiveObjects, type PathObject } from 'ably/liveobjects'; import { nanoid } from 'nanoid'; import { config } from './config'; import './styles.css'; -export enum Color { +type ColorCounters = { + [Color.red]: LiveCounter; + [Color.green]: LiveCounter; + [Color.blue]: LiveCounter; +}; + +enum Color { red = 'red', green = 'green', blue = 'blue', @@ -13,75 +19,63 @@ export enum Color { const client = new Realtime({ clientId: nanoid(), key: config.ABLY_KEY, - plugins: { Objects }, + plugins: { LiveObjects }, }); const channelName = config.CHANNEL_NAME || 'objects-live-counter'; -const channel = client.channels.get(channelName, { modes: ['OBJECT_PUBLISH', 'OBJECT_SUBSCRIBE'] }); +const channel = client.channels.get(channelName, { modes: ['object_publish', 'object_subscribe'] }); -const colorCountDivs: Record = { - red: document.getElementById('count-red')!, - green: document.getElementById('count-green')!, - blue: document.getElementById('count-blue')!, +const colorCountDivs: Record = { + red: document.getElementById('count-red'), + green: document.getElementById('count-green'), + blue: document.getElementById('count-blue'), }; -const countersReset = document.getElementById('reset')!; +const countersReset = document.getElementById('reset'); async function main() { - await channel.attach(); - - const objects = channel.objects; - const root = await objects.getRoot(); + const countersObject = await channel.object.get(); - await initCounters(root); - addEventListenersToButtons(root); + await initCounters(countersObject); + addEventListenersToButtons(countersObject); } -async function initCounters(root: LiveMap) { - // subscribe to root to get notified when counter objects get changed on the root. - // for example, when we reset all counters - root.subscribe(({ update }) => { - Object.entries(update).forEach(([keyName, change]) => { - if (change === 'removed') { - return; - } - - if (Object.values(Color).includes(keyName as Color)) { - // key pointing to a counter object got updated, resubscribe to a counter - const color = keyName as Color; - subscribeToCounterUpdates(color, root.get(color)!); - } - }); - }); - +async function initCounters(counters: PathObject>) { await Promise.all( Object.values(Color).map(async (color) => { - if (root.get(color)) { - subscribeToCounterUpdates(color, root.get(color)!); - return; - } + subscribeToCounterUpdates(color, counters.get(color)); - await root.set(color, await channel.objects.createCounter()); + // Initialize counter if it doesn't exist + if (counters.get(color).value() === undefined) { + await counters.set(color, LiveCounter.create()); + } }), ); } -function subscribeToCounterUpdates(color: Color, counter: LiveCounter) { +function subscribeToCounterUpdates(color: Color, counter: PathObject) { counter.subscribe(() => { - colorCountDivs[color].innerHTML = counter.value().toString(); + if (colorCountDivs[color]) { + colorCountDivs[color].innerHTML = counter.value()?.toString() ?? '0'; + } }); - colorCountDivs[color].innerHTML = counter.value().toString(); + if (colorCountDivs[color]) { + colorCountDivs[color].innerHTML = counter.value()?.toString() ?? '0'; + } } -function addEventListenersToButtons(root: LiveMap) { +function addEventListenersToButtons(counters: PathObject>) { document.querySelectorAll('.vote-button').forEach((button) => { const color = button.getAttribute('data-color') as Color; button.addEventListener('click', () => { - root.get(color)?.increment(1); + counters.get(color).increment(1); }); }); - countersReset.addEventListener('click', () => { - Object.values(Color).forEach(async (color) => root.set(color, await channel.objects.createCounter())); + countersReset?.addEventListener('click', () => { + // Use batch to reset all counters atomically + counters.batch((ctx) => { + Object.values(Color).forEach((color) => ctx.set(color, LiveCounter.create())); + }); }); } diff --git a/examples/liveobjects-live-map/javascript/README.md b/examples/liveobjects-live-map/javascript/README.md index 960405387f..ba94697adc 100644 --- a/examples/liveobjects-live-map/javascript/README.md +++ b/examples/liveobjects-live-map/javascript/README.md @@ -2,24 +2,27 @@ Enable clients to update and synchronize key/value data in an application in realtime. -LiveMap is a key/value data structure that synchronizes its state across users in realtime. It enables you to store primitive values, such as numbers, strings, booleans and buffers, as well as other objects, enabling you to build complex, hierarchical object structure. +[`LiveMap`](/docs/liveobjects/map) is a key/value data structure that synchronizes its state across users in realtime. It enables you to store primitive values, such as numbers, strings, booleans, binary data, JSON-serializable objects or arrays and other live [object types](/docs/liveobjects/concepts/objects#object-types), enabling you to build complex, hierarchical channel objects. -LiveMap can be used to store both predefined and dynamic datasets that need to be updated in realtime. They are ideal for scenarios such as collaborative task management, live leaderboards, multiplayer game state, shared settings, or live dashboards. +`LiveMap` can be used to store both predefined and dynamic datasets that need to be updated in realtime. It is ideal for scenarios such as collaborative task management, live leaderboards, multiplayer game state, shared settings, or live dashboards. -LiveMap is implemented using [Ably LiveObjects](/docs/liveobjects). LiveObjects, as a feature of [Ably Pub/Sub](/docs/channels), contains a set of purpose-built APIs that abstract away the complexities of managing shared state between clients in an application. It is built on top of Ably's core platform, and so it provides the same performance guarantees and scaling potential. +`LiveMap` is a feature of [LiveObjects](/docs/liveobjects), which provides a serverless, durable, and scalable way to create, update, and subscribe to shared state across large numbers of connected clients at any scale. LiveObjects is built on [channels](/docs/channels) and provides the same [performance guarantees and scaling potential](/docs/platform/architecture). ## Resources -Use the following methods to interact with a LiveMap in your application: +Use the following methods to interact with a `LiveMap` in your application: -- [`objects.getRoot()`](/docs/liveobjects/concepts/objects#root-object): retrieves the root object that serves as the starting point for storing and organizing objects on a channel. -- [`objects.createMap()`](/docs/liveobjects/map#create): creates a new LiveMap instance. -- [`liveMap.get(key)`](/docs/liveobjects/map#get): returns the current value associated with a given key in the map. -- [`liveMap.set(key, value)`](/docs/liveobjects/map#set): sends the operation message to the Ably system to assign a value to a key in the map. -- [`liveMap.remove(key)`](/docs/liveobjects/map#remove): sends the operation message to the Ably system to remove a key from the map. -- [`liveMap.subscribe()`](/docs/liveobjects/map#subscribe-data): subscribes to LiveMap updates by registering a listener. +- [`channel.object.get()`](/docs/liveobjects/concepts/path-object): retrieve a [`PathObject`](/docs/liveobjects/concepts/path-object) reference to the [channel object](/docs/liveobjects/concepts/objects#channel-object). Use this `PathObject` to update and subscribe to data via: + - [`get(key)`](/docs/liveobjects/concepts/path-object#navigate): navigate to child paths within the `PathObject`, such as entries in a `LiveMap`. + - [`set(key, value)`](/docs/liveobjects/concepts/path-object#update): assign a value to a key in the `LiveMap`. + - [`remove(key)`](/docs/liveobjects/concepts/path-object#delete): send an [operation](/docs/liveobjects/concepts/operations) to remove a key from the `LiveMap`. + - [`value()`](/docs/liveobjects/concepts/path-object#read-values): read the current value at the specified path. + - [`entries()`](/docs/liveobjects/concepts/path-object#iterate): iterate over key-value pairs in the `LiveMap`. + - [`subscribe()`](/docs/liveobjects/concepts/path-object#subscribe): subscribe to updates at the specified path by registering a listener. + - [`batch()`](/docs/liveobjects/concepts/path-object#batch-multiple-updates): group multiple operations into a single message for atomic updates. +- [`LiveMap.create()`](/docs/liveobjects/map#create): create a new `LiveMap` instance. -Find out more about [LiveMap](/docs/liveobjects/map). +Find out more about [PathObject](/docs/liveobjects/concepts/path-object) and [LiveMap](/docs/liveobjects/map). ## Getting started diff --git a/examples/liveobjects-live-map/javascript/src/ably.config.d.ts b/examples/liveobjects-live-map/javascript/src/ably.config.d.ts deleted file mode 100644 index d342f3f14c..0000000000 --- a/examples/liveobjects-live-map/javascript/src/ably.config.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { LiveMap } from 'ably'; - -export type Tasks = LiveMap<{ [key: string]: string }>; - -declare global { - export interface AblyObjectsTypes { - root: { - tasks: Tasks; - }; - } -} diff --git a/examples/liveobjects-live-map/javascript/src/script.ts b/examples/liveobjects-live-map/javascript/src/script.ts index 741fb0c0d7..26202eb930 100644 --- a/examples/liveobjects-live-map/javascript/src/script.ts +++ b/examples/liveobjects-live-map/javascript/src/script.ts @@ -1,78 +1,79 @@ -import { DefaultRoot, LiveMap, Realtime } from 'ably'; -import Objects from 'ably/objects'; +import { Realtime } from 'ably'; +import { LiveMap, LiveObjects, type PathObject } from 'ably/liveobjects'; import { nanoid } from 'nanoid'; -import { Tasks } from './ably.config'; import { config } from './config'; import './styles.css'; +export type Tasks = Record; + const client = new Realtime({ clientId: nanoid(), key: config.ABLY_KEY, - plugins: { Objects }, + plugins: { LiveObjects }, }); const channelName = config.CHANNEL_NAME || 'objects-live-map'; -const channel = client.channels.get(channelName, { modes: ['OBJECT_PUBLISH', 'OBJECT_SUBSCRIBE'] }); +const channel = client.channels.get(channelName, { modes: ['object_publish', 'object_subscribe'] }); const taskInput = document.getElementById('task-input') as HTMLInputElement; -const addTaskButton = document.getElementById('add-task')!; -const tasksDiv = document.getElementById('tasks')!; -const removeAllTasksDiv = document.getElementById('remove-tasks')!; +const addTaskButton = document.getElementById('add-task'); +const tasksDiv = document.getElementById('tasks'); +const removeAllTasksDiv = document.getElementById('remove-tasks'); -async function main() { - await channel.attach(); +if (!addTaskButton || !tasksDiv || !removeAllTasksDiv) { + throw new Error('Required DOM elements not found'); +} - const objects = channel.objects; - const root = await objects.getRoot(); +async function main() { + const tasksObject = await channel.object.get(); - await initTasks(root); - addEventListenersToButtons(root); + await initTasks(tasksObject); + addEventListenersToButtons(tasksObject); } -async function initTasks(root: LiveMap) { - // subscribe to root to get notified when tasks object gets changed on the root. - // for example, when we clear all tasks - root.subscribe(({ update }) => { - if (update.tasks === 'updated') { - subscribeToTasksUpdates(root.get('tasks')!); +async function initTasks(tasks: PathObject>) { + // Subscribe to all changes for the tasks object + tasks.subscribe(({ message }) => { + if (!message) { + return; } - }); - if (root.get('tasks')) { - subscribeToTasksUpdates(root.get('tasks')!); - return; - } + // Handle individual task updates + const { operation } = message; + if (operation.action === 'map.set' && operation.mapOp?.key) { + tasksOnUpdated(operation.mapOp.key, tasks); + } else if (operation.action === 'map.remove' && operation.mapOp?.key) { + tasksOnRemoved(operation.mapOp.key); + } + }); - await root.set('tasks', await channel.objects.createMap()); + // Render initial state + renderAllTasks(tasks); } -function subscribeToTasksUpdates(tasks: Tasks) { - tasksDiv.innerHTML = ''; - - tasks.subscribe(({ update }) => { - Object.entries(update).forEach(async ([taskId, change]) => { - switch (change) { - case 'updated': - tasksOnUpdated(taskId, tasks); - break; - case 'removed': - tasksOnRemoved(taskId); - break; - } - }); - }); - +function renderAllTasks(tasks: PathObject>) { + if (tasksDiv) { + tasksDiv.innerHTML = ''; + } for (const [taskId] of tasks.entries()) { - createTaskDiv({ id: taskId, title: tasks.get(taskId)! }, tasks); + const title = tasks.get(taskId).value(); + if (title) { + createTaskDiv(taskId, title, tasks); + } } } -function tasksOnUpdated(taskId: string, tasks: Tasks) { +function tasksOnUpdated(taskId: string, tasks: PathObject>) { + const title = tasks.get(taskId).value(); + if (!title) { + return; + } + const taskSpan = document.querySelector(`.task[data-task-id="${taskId}"] > span`); if (taskSpan) { - taskSpan.innerHTML = tasks.get(taskId)!; + taskSpan.innerHTML = title; } else { - createTaskDiv({ id: taskId, title: tasks.get(taskId)! }, tasks); + createTaskDiv(taskId, title, tasks); } } @@ -80,9 +81,7 @@ function tasksOnRemoved(taskId: string) { document.querySelector(`.task[data-task-id="${taskId}"]`)?.remove(); } -function createTaskDiv(task: { id: string; title: string }, tasks: Tasks) { - const { id, title } = task; - +function createTaskDiv(id: string, title: string, tasks: PathObject>) { const parser = new DOMParser(); const taskDiv = parser.parseFromString( `
@@ -91,24 +90,36 @@ function createTaskDiv(task: { id: string; title: string }, tasks: Tasks) {
`, 'text/html', - ).body.firstChild as HTMLElement; + ).body.firstChild; - tasksDiv.appendChild(taskDiv); + if (!(taskDiv instanceof HTMLElement)) { + throw new Error('Failed to create task element'); + } - taskDiv.querySelector('.update-task')!.addEventListener('click', async () => { - const newTitle = prompt('New title for a task:'); - if (!newTitle) { - return; - } - await tasks.set(id, newTitle); - }); - taskDiv.querySelector('.remove-task')!.addEventListener('click', async () => { - await tasks.remove(id); - }); + tasksDiv?.appendChild(taskDiv); + + const updateButton = taskDiv.querySelector('.update-task'); + const removeButton = taskDiv.querySelector('.remove-task'); + + if (updateButton) { + updateButton.addEventListener('click', async () => { + const newTitle = prompt('New title for a task:'); + if (!newTitle) { + return; + } + await tasks.set(id, newTitle); + }); + } + + if (removeButton) { + removeButton.addEventListener('click', async () => { + await tasks.remove(id); + }); + } } -function addEventListenersToButtons(root: LiveMap) { - addTaskButton.addEventListener('click', async () => { +function addEventListenersToButtons(tasks: PathObject>) { + addTaskButton?.addEventListener('click', async () => { const taskTitle = taskInput.value.trim(); if (!taskTitle) { return; @@ -116,11 +127,16 @@ function addEventListenersToButtons(root: LiveMap) { const taskId = nanoid(); taskInput.value = ''; - await root.get('tasks')?.set(taskId, taskTitle); + await tasks.set(taskId, taskTitle); }); - removeAllTasksDiv.addEventListener('click', async () => { - await root.set('tasks', await channel.objects.createMap()); + removeAllTasksDiv?.addEventListener('click', async () => { + // Use batch to remove all tasks atomically + tasks.batch((ctx) => { + for (const [taskId] of tasks.entries()) { + ctx.remove(taskId); + } + }); }); } diff --git a/examples/package.json b/examples/package.json index 0c922822de..87f7ef1cd0 100644 --- a/examples/package.json +++ b/examples/package.json @@ -105,7 +105,7 @@ "@ably/chat": "^1.1.0", "@ably/chat-react-ui-kit": "^0.3.0", "@ably/spaces": "^0.4.0", - "ably": "^2.14.0", + "ably": "^2.16.0", "cors": "^2.8.5", "franken-ui": "^2.0.0", "lodash": "^4.17.21", diff --git a/examples/yarn.lock b/examples/yarn.lock index 6a906a8bc7..246e3a2c94 100644 --- a/examples/yarn.lock +++ b/examples/yarn.lock @@ -900,10 +900,10 @@ magic-string "^0.27.0" react-refresh "^0.14.0" -ably@^2.14.0: - version "2.14.0" - resolved "https://registry.yarnpkg.com/ably/-/ably-2.14.0.tgz#257b762d6c5af27e42ad885e1e2fc650568ac3a3" - integrity sha512-GWNza+URnh/W5IuoJX7nXJpQCs2Dxby6t5A20vL3PBqGIJceA94/1xje4HOZbqFtMEPkRVsYHBIEuQRWL+CuvQ== +ably@^2.16.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/ably/-/ably-2.16.0.tgz#b4042182e9ea54e621c60eb76997b3f760901fb4" + integrity sha512-X7SdHJC2ybCKAcFyyvi/VAN903q7JnEqdtpOXMM6TNWdNj/b40a4ijzEX/9lXSKddUJCiYM2KaFaVnSRn90YMw== dependencies: "@ably/msgpack-js" "^0.4.0" dequal "^2.0.3" diff --git a/src/components/Examples/ExamplesRenderer.tsx b/src/components/Examples/ExamplesRenderer.tsx index 0e87f88f30..6626993a43 100644 --- a/src/components/Examples/ExamplesRenderer.tsx +++ b/src/components/Examples/ExamplesRenderer.tsx @@ -37,7 +37,7 @@ const UserIndicator = ({ user }: { user: string }) => { const getDependencies = (id: string, products: string[], activeLanguage: LanguageKey) => { return { - ably: '^2.14.0', + ably: '^2.16.0', nanoid: '^5.0.7', minifaker: '1.34.1', 'franken-ui': '^2.0.0', diff --git a/src/data/examples/index.ts b/src/data/examples/index.ts index ce75f4d31a..6e6467b355 100644 --- a/src/data/examples/index.ts +++ b/src/data/examples/index.ts @@ -93,7 +93,7 @@ export const examples: Example[] = [ products: ['liveobjects'], languages: ['javascript'], layout: 'double-horizontal', - visibleFiles: ['src/script.ts', 'App.tsx', 'index.tsx'], + visibleFiles: ['src/script.ts', 'index.tsx'], metaTitle: `Build a live counter with Ably’s LiveObjects`, metaDescription: `Use Ably’s LiveObjects to synchronize numerical values in realtime with a LiveCounter. Power data synchronization at scale for votes, reaction counts, game stats, and more.`, }, @@ -104,7 +104,7 @@ export const examples: Example[] = [ products: ['liveobjects'], languages: ['javascript'], layout: 'double-horizontal', - visibleFiles: ['src/script.ts', 'App.tsx', 'index.tsx'], + visibleFiles: ['src/script.ts', 'index.tsx'], metaTitle: `Build a live map with Ably’s LiveObjects`, metaDescription: `Use Ably’s LiveObjects to synchronize key/value data in realtime with a LiveMap. Build dynamic, collaborative apps with reliable, low-latency synchronization at scale.`, },