From 6d37df93aededa39f15a3c152237d0654c30014f Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 5 Mar 2025 09:11:01 +0000 Subject: [PATCH 01/38] Add LiveObjects product to site navigation This was added by searching for "livesync" in "src" folder and adding corresponding "liveObjects" sections where necessary. --- .../ProductNavigation/ProductNavigation.tsx | 4 ++ src/components/common/meta-title.test.ts | 1 + src/components/common/meta-title.ts | 1 + src/data/content/homepage.ts | 7 ++ src/data/index.ts | 5 ++ src/data/languages/languageData.ts | 13 ++-- src/data/nav/index.ts | 11 ++- src/data/nav/liveobjects.ts | 67 +++++++++++++++++++ src/data/types.ts | 2 +- src/templates/template-data.d.ts | 11 ++- 10 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 src/data/nav/liveobjects.ts diff --git a/src/components/ProductNavigation/ProductNavigation.tsx b/src/components/ProductNavigation/ProductNavigation.tsx index c5cf0fec6c..c9afba25c5 100644 --- a/src/components/ProductNavigation/ProductNavigation.tsx +++ b/src/components/ProductNavigation/ProductNavigation.tsx @@ -28,6 +28,7 @@ const ProductNavigation = ({ currentProduct = 'channels' }: { currentProduct?: s const onChannels = currentProduct === 'channels'; const onLiveSync = currentProduct === 'livesync'; const onChat = currentProduct === 'chat'; + const onLiveObjects = currentProduct === 'liveobjects'; const onAssetTracking = currentProduct === 'asset-tracking'; return ( @@ -50,6 +51,9 @@ const ProductNavigation = ({ currentProduct = 'channels' }: { currentProduct?: s Chat + + LiveObjects + Asset Tracking diff --git a/src/components/common/meta-title.test.ts b/src/components/common/meta-title.test.ts index a0aaecc50e..215b105507 100644 --- a/src/components/common/meta-title.test.ts +++ b/src/components/common/meta-title.test.ts @@ -7,6 +7,7 @@ describe('getMetaTitle', () => { ['spaces', 'Ably Spaces', 'Setup'], ['livesync', 'Ably LiveSync', 'Begin'], ['chat', 'Ably Chat', 'Emojis'], + ['liveobjects', 'Ably LiveObjects', 'Setup'], ['asset-tracking', 'Ably Asset Tracking', 'Examples'], ['api-reference', 'API References', 'Setup'], ['pub_sub', 'Ably Pub/Sub', 'Authentication'], diff --git a/src/components/common/meta-title.ts b/src/components/common/meta-title.ts index 4eb2dc9f08..609317da7c 100644 --- a/src/components/common/meta-title.ts +++ b/src/components/common/meta-title.ts @@ -6,6 +6,7 @@ export const getMetaTitle = (title: string, product: ProductName): string => { spaces: 'Ably Spaces', livesync: 'Ably LiveSync', chat: 'Ably Chat', + liveobjects: 'Ably LiveObjects', 'asset-tracking': 'Ably Asset Tracking', 'api-reference': 'API References', pub_sub: 'Ably Pub/Sub', diff --git a/src/data/content/homepage.ts b/src/data/content/homepage.ts index 994973bc32..6eb42d0c04 100644 --- a/src/data/content/homepage.ts +++ b/src/data/content/homepage.ts @@ -53,6 +53,13 @@ export default { image: 'spaces.png', links: [{ text: 'Get started', href: '/docs/spaces' }], }, + { + title: 'LiveObjects', + type: 'feature', + content: 'Seamlessly sync application state between clients.', + image: 'liveobjects.png', + links: [{ text: 'Get started', href: '/docs/liveobjects' }], + }, { title: 'LiveSync', type: 'feature', diff --git a/src/data/index.ts b/src/data/index.ts index 374d5aaedf..7a2ac779d9 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,6 +1,7 @@ import { assetTrackingNavData, chatNavData, + liveObjectsNavData, liveSyncNavData, platformNavData, pubsubNavData, @@ -27,6 +28,10 @@ export const productData = { nav: spacesNavData, languages: languageData.spaces, }, + liveObjects: { + nav: liveObjectsNavData, + languages: languageData.liveObjects, + }, liveSync: { nav: liveSyncNavData, languages: languageData.liveSync, diff --git a/src/data/languages/languageData.ts b/src/data/languages/languageData.ts index 0af365cf99..b4abe681db 100644 --- a/src/data/languages/languageData.ts +++ b/src/data/languages/languageData.ts @@ -3,13 +3,13 @@ import { LanguageData } from './types'; export default { platform: { - javascript: 2.6, - nodejs: 2.6, + javascript: 2.7, + nodejs: 2.7, }, pubsub: { - javascript: 2.6, - nodejs: 2.6, - react: 2.6, + javascript: 2.7, + nodejs: 2.7, + react: 2.7, csharp: 1.2, flutter: 1.2, java: 1.2, @@ -31,6 +31,9 @@ export default { javascript: 0.4, react: 0.4, }, + liveObjects: { + javascript: 2.7, + }, liveSync: { javascript: 0.4, }, diff --git a/src/data/nav/index.ts b/src/data/nav/index.ts index e50b2997bf..6092966fd7 100644 --- a/src/data/nav/index.ts +++ b/src/data/nav/index.ts @@ -1,8 +1,17 @@ import platformNavData from './platform'; import pubsubNavData from './pubsub'; import chatNavData from './chat'; +import liveObjectsNavData from './liveobjects'; import spacesNavData from './spaces'; import liveSyncNavData from './livesync'; import assetTrackingNavData from './assettracking'; -export { platformNavData, pubsubNavData, chatNavData, spacesNavData, liveSyncNavData, assetTrackingNavData }; +export { + platformNavData, + pubsubNavData, + chatNavData, + liveObjectsNavData, + spacesNavData, + liveSyncNavData, + assetTrackingNavData, +}; diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts new file mode 100644 index 0000000000..02e1593d47 --- /dev/null +++ b/src/data/nav/liveobjects.ts @@ -0,0 +1,67 @@ +import { NavProduct } from './types'; + +export default { + name: 'Ably LiveObjects', + link: '/docs/liveobjects', + icon: { + closed: 'icon-product-liveobjects-mono', + open: 'icon-product-liveobjects', + }, + content: [ + { + name: 'Introduction', + pages: [ + { + name: 'About LiveObjects', + link: '/docs/liveobjects', + }, + ], + }, + { + name: 'Get started', + pages: [ + { + name: 'Quickstart', + link: '/docs/liveobjects/quickstart', + }, + ], + }, + { + name: 'LiveObjects features', + pages: [ + { + name: 'LiveCounter', + link: '/docs/liveobjects/counter', + }, + { + name: 'LiveMap', + link: '/docs/liveobjects/map', + }, + { + name: 'Batch Operations', + link: '/docs/liveobjects/batch', + }, + { + name: 'Lifecycle Events', + link: '/docs/liveobjects/lifecycle', + }, + { + name: 'Typing', + link: '/docs/liveobjects/typing', + }, + ], + }, + ], + api: [ + { + name: 'API References', + pages: [ + { + link: 'https://ably.com/docs/sdk/js/v2.0/interfaces/ably.Objects.html', + name: 'JavaScript SDK', + external: true, + }, + ], + }, + ], +} satisfies NavProduct; diff --git a/src/data/types.ts b/src/data/types.ts index f194a03243..f5886f7f26 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -3,7 +3,7 @@ import { LanguageData } from './languages/types'; import { NavProduct } from './nav/types'; const pageKeys = ['homepage'] as const; -const productKeys = ['platform', 'pubsub', 'chat', 'spaces', 'liveSync', 'assetTracking'] as const; +const productKeys = ['platform', 'pubsub', 'chat', 'spaces', 'liveObjects', 'liveSync', 'assetTracking'] as const; export type ProductKey = (typeof productKeys)[number]; type PageKey = (typeof pageKeys)[number]; diff --git a/src/templates/template-data.d.ts b/src/templates/template-data.d.ts index 9776ab61e4..769839f439 100644 --- a/src/templates/template-data.d.ts +++ b/src/templates/template-data.d.ts @@ -35,13 +35,22 @@ export type AblyPageContext = { script: string; }; -export type ProductName = 'channels' | 'spaces' | 'livesync' | 'chat' | 'asset-tracking' | 'api-reference' | 'home'; +export type ProductName = + | 'channels' + | 'spaces' + | 'livesync' + | 'chat' + | 'liveobjects' + | 'asset-tracking' + | 'api-reference' + | 'home'; export type ProductTitle = | 'Channels' | 'Ably Spaces' | 'Ably LiveSync' | 'Ably Chat' + | 'Ably LiveObjects' | 'Ably Asset Tracking' | 'API References' | 'Home' From fab36af87cb0ed2352916a4a9f1bea4029c94b81 Mon Sep 17 00:00:00 2001 From: zak Date: Mon, 24 Mar 2025 10:52:27 +0000 Subject: [PATCH 02/38] Live objects: Add REST API docs Add new page in the "API References" part of the product docs which details the REST APIs available for interacting with live objects. --- content/liveobjects/rest-api.textile | 435 +++++++++++++++++++++++++++ src/data/nav/liveobjects.ts | 4 + 2 files changed, 439 insertions(+) create mode 100644 content/liveobjects/rest-api.textile diff --git a/content/liveobjects/rest-api.textile b/content/liveobjects/rest-api.textile new file mode 100644 index 0000000000..594db0247f --- /dev/null +++ b/content/liveobjects/rest-api.textile @@ -0,0 +1,435 @@ +--- +title: REST API Reference +meta_description: "Ably provides the raw REST API for interacting with the LiveObjects stored on a channel." +meta_keywords: "REST API, REST, LiveObjects, objects" +section: api +index: 100 +--- + +h2(#fetching-objects). Fetching objects + +There are three APIs for fetching objects stored on a channel: + +1. List - returns a list of objects. +2. Object - returns the objects in a tree starting from the given object. +3. Compact - returns the objects in a compact tree format. + +h3(#list-objects). List objects + +Use the list endpoint to fetch a list of objects stored on the channel. + +h6. GET rest.ably.io/channels//objects + +Returns the IDs of the live objects store on the channel. + +bc(json). [ + "root", + "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000", + "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" +] + +To include values, set the @values=true@ query parameter. + +h6. GET rest.ably.io/channels//objects?values=true + +bc(json). [ + { + "objectId": "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000", + "map": { + "entries": { + "myMapKey": { "data": { "string": "my map value" } }, + "myObjectRef": { "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" } } + } + } + }, + { + "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000", + "counter": { "data": { "number": 5 } } + } +] + +h3(#data-values). Data values + +In the list API, data values represent a concrete piece of data (a number, a string, etc) or a reference to another object. The key in the data value indicates the type that you can expect to receive in the value. + +bc(json). { "data": { "number" : 4 }} +{ "data": { "string" : "Ably Pub/Sub" }} +{ "data": { "boolean" : true }} +{ "data": { "bytes": "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=", "encoding": "base64" }} +{ "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" }} + +Maps are made of entries, which are a user-defined key (e.g. "myMapKey") and a data value. Counters are data values containing a number. + +h3(#query-params). Query parameters + +|_. Param |_. Description | +|values|@true@ or @false@, default is @false@. Include the values in the response. Setting this to @false@ returns a list objectIds only. | +|limit |Set the number of objects to be returned in each page. | +|cursor | The cursor used for "pagination.":/docs/api/rest-api#pagination | +|metadata| @true@ or @false@, default is @false@. Include "object metadata":#metadata in the response. | + +h3(#get-object). Get an object + +Use the object API to fetch a live object stored on the channel in a tree structure. + +h6. GET rest.ably.io/channels//objects/ + +bc(json). { + "objectId": "root", + "map": { + "entries": { + "myMapKey": { "data": { "string": "my map value" }}, + "myObjectRef": { + "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" } + } + } + } +} + +To replace the object references with the actual values (inlined), set the @children=true@ query parameter. This query param causes the content of the objects to be included in the tree response, instead of just the objectIds. + +h6. GET rest.ably.io/channels//objects/?children=true + +bc(json). { + "objectId": "root", + "map": { + "entries": { + "myMapKey": { "data": { "string": "my map value" }}, + "myObjectRef": { + "data": { + "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000", + "counter": { "data": { "number": 5 }} + } + } + } + } +} + +Use @root@ as the objectId in the URL to get the full object tree, or any other objectId to fetch a subset of the tree. + +The traverse API also supports requesting metadata with @metadata=true@ query param. + +You can limit the number of objects included in the response using the @limit@ query param. + +h3(#get-compact-object). Get a compact object + +The compact API returns a tree structure of the objects in a concise format that's easier to unmarshal into data types that represent your state. To fetch the full object tree, use the objectId @root@. + +h6. GET rest.ably.io/channels//objects//compact + +bc(json). { + "myMapKey": "my map value", + "myCounter": 5 + "myNestedMap": { + "nestedKey": "nested value" + } +} + + +Map keys will be inlined as json object keys, maps will be json objects, and counters will be inlined directly with the counter value. + +You can limit the number of objects included in the response using the @limit@ query param. + +Note, cyclic and diamond references will be included as objectId references rather than including a single object in the response more than once. The compact API response schema does not distinguish between a string value and an objectId. + +h3(#metadata). Metadata + +Object metadata describes the internal details of an object. There are two metadata fields that are common to all objects: + +|_. Field |_. Description | +| Tombstone | true or false, indicates if the object has been deleted. Objects will exist as tombstones for a short while to ensure they remain deleted and are not accidentally created by a lagging live objects client. | +|SiteTimeserials |a map of sites, and the last operation applied to this object from that site. | + +Maps have additional metadata attached to their map entries. + +|_. Field |_. Description | +| MapSemantics | Indicates the conflict resolution method used in this map. | +| Timeserial | The last operation applied to this map key, used to preserve the map semantics.| +| Tombstone | true or false, indicates if the map entry has been deleted. Tombstoned map values are not included in the responses by default or when using the @metadata=false@ query param. | + +Example object with metadata included: + +bc(json). { + "objectId": "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000", + "map": { + "mapSemantics": "LWW", + "entries": { + "myMapKey": { + "timeserial": "...", + "tombstone": false, + "data": { "string": "my map value" } + }, + "myObjectRef": { + "timeserial": "...", + "tombstone": true, + "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" } + } + } + } +} + +h3(#tombstones). Tombstones + +Tombstones are used on objects and on map entires to indicate that the object or map entry has been deleted. This protects against lagging live objects clients from re-introducing a deleted value by accident. + +Tombstone objects are not included by default, but can be enabled using @metadata=true@ query param. + +Tombstone map entry keys are included by default, but the values are not. The key will be present in the entries object but with a null value e.g. @myKey: null@. Tombstoned map entry values can be included using the @metadata=true@ flag. + +h3(#cyclic-and-diamond-references). Cyclic and diamond references + +In the List, Get object, and Get compact object APIs objects will only be included in the response once. For cyclic or diamond references, the later references to any object that was already included will have an objectIds only. The reference will be included even if you request the data to be inlined using query params, as objects will only appear in the response once. + + +h2(#issuing-operations). Issuing operations + +h6. POST rest.ably.io/channels//objects + +h3(#maps). Map operations + +h4(#map-create). Create a map + +bc(json). { + "operation": "MAP_CREATE", + "data": { + "myMapKey": {"string": "myMapValue"}, + "myOtherKey": {"boolean": true} + } +} + +The @data@ field is made up of your map keys, and a "data" value [^link to above]. You do not have to set a value on the map when you create it. + +You do not have to provide an objectId when you create a map. The server will assign the correct objectId and return it in the response: + +bc(json). { + "channel": "", + "objectIds": ["map:Pm/NQtjxtARMnLFMUiw8U+eeiayy0kClduUZlH0ag30@1742555472000"] +} + +You can create an objectId and provide it in the create request. The server will validate the objectId is the correct format. See the "ObjectId":#object-id-format format section. + +bc(json). { + "operation": "MAP_CREATE", + "objectId": "map:99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_-yXQE@1742207566771", + "nonce": "myNonce", + "data": {"myMapKey":{"string":"myMapValue"}} +} + +h4(#map-set). Set a value on a map + +To set a value on a map, you need to provide the objectId of the map you wish to set the key and value on. The data value consists of a @key@ and @value@. Like before the @value@ carries an object that indicates the type of the value being set. + +bc(json). { + "operation": "MAP_SET", + "objectId": "map:99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_@1742207566771", + "data": {"key":"foo", "value":{"string":"bar"}} +} + +h4(#map-remove). Remove a value from a map + +To remove a key and value from a map, provide the objectId of the map you wish to modify, and the key to remove. + +bc(json). { + "operation": "MAP_REMOVE", + "objectId": "map:99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_@1742207566771", + "data": {"key":"myMapKey"} +} + +h3(#counters). Counter operations + +h4(#counter-create). Create a counter + +The counter create takes a @data@ value with a @number@ field. This number is the initial value that the counter will be initialised to. + +bc(json). { + "operation": "COUNTER_CREATE", + "data": { "number": 3.1415926 } +} + + +You can create a counter with an objectId, rather than relying on the server to assign the objectId. + +Response: + +bc(json). { + "channel": "", + "objectIds": ["counter:u41d1-DfkEt1AtbyJUSUJn3qAFblVVGmx5Dpg-ToCeI@1734628392000"] +} + +h4(#counter-create). Increment a counter + +To increment a counter, you must provide the objectId of the counter that you are going to increment. You can increment by a negative value. + +bc(json). { + "operation": "COUNTER_INC", + "objectId": "counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000", + "data": {"number": 2} +} + +h3(#remove-object). Remove an object + +There is no explicit delete operation for objects. Objects that are reachable from the root map are kept, any object that is not reachable from the root map is elligible for periodic garbage collection. + +To remove an object, update the object tree to remove any reference to that objectId, and the object will be elligible for garbage collection. + +h2(#object-id-format). ObjectId format + +An objectId is made of the following components: + +bc. objectType:base64hash@millisecondTimestamp +// Example: +counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000 + +The object types are @map@ or @counter@. + +The millisecond timestamp is "now". There is a small leeway to compensate for server clocks being out of sync. You can fetch the ably server time using "@rest.ably.io/time@":/docs/api/rest-api#time. + +The base64 raw url encoding hash is made of @initialValue:nonce@. The initial value is the raw bytes taken from the @data@ field on create operations. The @nonce@ is any random string. + +Examples: + +|_. Initial value |_. Nonce |_. Result hash | +|@{"value":3.1415926539}@ | @nonce@ | @u41d1-DfkEt1AtbyJUSUJn3qAFblVVGmx5Dpg-ToCeI@ | +|@{"foo":{"string":"bar"}}@ | @nonce@ | @ME2yWbb_6sK5AlwxklHA8mTBPxPZx9iyW2Zk6rKJfRs@ | +|@{"myMapKey":{"string":"myMapValue"}}@ | @myNonce@ |@99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_-yXQE@ | + +Note: the initial value is space and case sensitive. + +The Operations API accepts the "data" field as either a json object, or as a string with an encoding field. For example, this request passes the data field for a counter as a string with json encoding. + +bc(json). { + "operation": "COUNTER_CREATE", + "data": "{ \"number\": 3.1415926 }", + "encoding": "json" +} + +h2(#batch-operations). Batch operations + +You can pass a list of operations to the operations endpoint. + +h6. POST rest.ably.io/channels//objects + +bc(json). [ + { + "operation": "MAP_SET" + "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", + "data": {"key": "isActive", "value": { "boolean": true }} + }, + { + "operation": "COUNTER_INC" + "objectId": "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmTrNEguJXo@1742481614000", + "data": {"number": 1} + } +] + +The response is the same as the single operation endpoint: + +bc(json). { + "channel": "", + "objectIds": [ + "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", + "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmTrNEguJXo@1742481614000" + ] +} + +It map be useful to generate an objectId client-side, so that you can create an object and modify it in the same batch operation. For example; create a map and set values on it in the same operation. + +Batches of operations issued in the REST API will remain a batch, and will be passed to clients as a batch. Other operations issued against that object will not be interleaved between operations within the same batch. + +h2(#path-operations). Path operations + +h6. POST rest.ably.io/channels//objects + +Use path operations to issue operations against objects based on their referenced location in the tree of objects stored on the channel. +Paths must start with a key in the 'root' map, and then reference keys in nested maps, until the target location. + +For example, increment the @likes@ counter in the @reactions@ map by 3. + +bc(json). { + "path": "reactions.likes", + "operation": "COUNTER_INC", + "data": { "number": 3 } +} + +Example object tree this path could match: + +bc(json). { + "reactions": { + "likes": + } +} + +Here, the root map contains a @reactions@ key, which has a map value. The @reactions@ map contains a single key @likes@, which has a counter value. So incrementing @reactions.likes@ will increment the @likes@ counter in the @reactions@ map. + +Response: + +bc(json). { + "channel": "", + "objectIds": [""] +} + +You can create an object, like a map or a counter, and assign it to a path in a single operation. For example, the following operation will create a counter in the @reactions@ map under the key @likes@. + +bc(json). { + "path": "reactions.likes", + "operation": "COUNTER_CREATE" +} + +When creating operations with a path, all of objects up to the last path component must exist. In the example above, the @reactions@ map must already exist on the root map for us to set a counter in it under the @likes@ key. +Create operations cannot: overwrite other objects that already exist, or use wildcards. + +h3(#wildcards). Wildcard paths + +You can issue a single operation against multiple objects at once using the wildcard @*@. For example, increment all @reactions@ counters by 1. + +bc(json). { + "path": "reactions.*", + "operation": "COUNTER_INC", + "data": { "number": 1 } +} + +Wildcards can be included at the end or in the middle of paths and will match exactly one level in the object tree. For example, this path increments all objects reachable from the root map that have a @likes@ counter: + +bc(json). { + "path": "*.likes", + "operation": "COUNTER_INC", + "data": { "number": 1 } +} + +If your map keys (that make up the path) already contain a @.@, you can escape the path using a @\.@ + +For example, increment the counter stored at the @foo.bar@ key on the root map: + +bc(json). { + "path": "foo\.bar", + "operation": "COUNTER_INC", + "data": { "number": 1 } +} + +h2(#idempotent-operations). Idempotent operations + +All operations support an @id@ field. Operations are deduplicated using "idempotent publishing":/docs/pub-sub/advanced#idempotency based on the operation's "id" field. + +bc(json). { + "id": "myIdempotencyKey", + "operation": "MAP_SET" + "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", + "data": {"key": "isActive", "value": { "boolean": true }} +} + +Batches of operations can be made idempotent using a compound key based on the format @:@. The index is the order of the operation in the batch. The base of the @id@ must be the same across all operations in the batch. + +bc(json). [ + { + "id": "myIdempotencyKey:0", + "operation": "MAP_SET" + "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", + "data": {"key": "isActive", "value": { "boolean": true }} + }, + { + "id": "myIdempotencyKey:1", + "operation": "COUNTER_INC" + "objectId": "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmTrNEguJXo@1742481614000", + "data": {"number": 1} + } +] diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index 02e1593d47..fc0627eaad 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -56,6 +56,10 @@ export default { { name: 'API References', pages: [ + { + link: '/docs/liveobjects/rest-api', + name: 'REST API', + }, { link: 'https://ably.com/docs/sdk/js/v2.0/interfaces/ably.Objects.html', name: 'JavaScript SDK', From 38014151076ef62e0de901c2e4a2a6237b47a08f Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Mon, 31 Mar 2025 14:29:31 +0100 Subject: [PATCH 03/38] liveobjects: add occupancy metrics --- content/channels/options/index.textile | 4 +++- content/integrations/webhooks/index.textile | 4 +++- content/partials/general/events/_batched_events.textile | 4 +++- content/partials/types/_channel_details.textile | 6 +++++- content/presence-occupancy/occupancy.textile | 6 +++++- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/content/channels/options/index.textile b/content/channels/options/index.textile index 114bc7924e..4d6349735e 100644 --- a/content/channels/options/index.textile +++ b/content/channels/options/index.textile @@ -380,7 +380,9 @@ The following is an example of an occupancy metric event: subscribers: 1, presenceConnections: 1, presenceMembers: 0, - presenceSubscribers: 1 + presenceSubscribers: 1, + objectPublishers: 1, + objectSubscribers: 1 } }, encoding: null, diff --git a/content/integrations/webhooks/index.textile b/content/integrations/webhooks/index.textile index a65b162cf3..1498bc6f55 100644 --- a/content/integrations/webhooks/index.textile +++ b/content/integrations/webhooks/index.textile @@ -415,7 +415,9 @@ The following is an example of a batched @channel lifecycle@ payload: "subscribers": 1, "presenceConnections": 1, "presenceMembers": 0, - "presenceSubscribers": 1 + "presenceSubscribers": 1, + "objectPublishers": 1, + "objectSubscribers": 1 } } } diff --git a/content/partials/general/events/_batched_events.textile b/content/partials/general/events/_batched_events.textile index 71f83f9d4a..1339b58e81 100644 --- a/content/partials/general/events/_batched_events.textile +++ b/content/partials/general/events/_batched_events.textile @@ -142,7 +142,9 @@ The following is an example of a batched @channel lifecycle@ payload: "subscribers": 1, "presenceConnections": 1, "presenceMembers": 0, - "presenceSubscribers": 1 + "presenceSubscribers": 1, + "objectPublishers": 1, + "objectSubscribers": 1 } } } diff --git a/content/partials/types/_channel_details.textile b/content/partials/types/_channel_details.textile index c618a7739c..1dd8925635 100644 --- a/content/partials/types/_channel_details.textile +++ b/content/partials/types/_channel_details.textile @@ -21,7 +21,9 @@ The following is an example of a @ChannelDetails@ JSON object: "subscribers": 1, "presenceConnections": 1, "presenceMembers": 0, - "presenceSubscribers": 1 + "presenceSubscribers": 1, + "objectPublishers": 1, + "objectSubscribers": 1 } } } @@ -47,3 +49,5 @@ The @occupancy@ attribute contains the @metrics@ attribute, which contains the f - presenceSubscribers := the number of connections that are authorised to subscribe to presence messages
__Type: @integer@__ - presenceConnections := the number of connections that are authorised to enter members into the presence channel
__Type: @integer@__ - presenceMembers := the number of members currently entered into the presence channel
__Type: @integer@__ +- objectPublishers := the number of connections that are authorised to publish updates to objects on the channel
__Type: @integer@__ +- objectSubscribers := the number of connections that are authorised to subscribe to objects on the channel
__Type: @integer@__ diff --git a/content/presence-occupancy/occupancy.textile b/content/presence-occupancy/occupancy.textile index 55c040cadc..c4feb80047 100644 --- a/content/presence-occupancy/occupancy.textile +++ b/content/presence-occupancy/occupancy.textile @@ -18,6 +18,8 @@ The following are the metric categories that occupancy reports: - presenceSubscribers := the number of connections that are authorized to subscribe to presence messages - presenceConnections := the number of connections that are authorized to enter members into the presence channel - presenceMembers := the number of members currently entered into the presence channel +- objectPublishers := the number of connections that are authorized to publish updates to objects on the channel +- objectSubscribers := the number of connections that are authorized to subscribe to objects on the channel h2(#occupancy-payload). Occupancy payload structure @@ -40,7 +42,9 @@ If occupancy is returned as a @[meta]occupancy@ event when subscribing to a chan subscribers: 1, presenceConnections: 1, presenceMembers: 0, - presenceSubscribers: 1 + presenceSubscribers: 1, + objectPublishers: 1, + objectSubscribers: 1 } }, encoding: null, From 630c522c31794ff04354229629fde2ded0aab9d8 Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Mon, 31 Mar 2025 14:33:31 +0100 Subject: [PATCH 04/38] liveobjects: add capabilities --- content/auth/capabilities.textile | 2 ++ .../partials/core-features/_authentication_capabilities.textile | 2 ++ 2 files changed, 4 insertions(+) diff --git a/content/auth/capabilities.textile b/content/auth/capabilities.textile index b5d04ad908..d8e189a954 100644 --- a/content/auth/capabilities.textile +++ b/content/auth/capabilities.textile @@ -42,6 +42,8 @@ The following capability operations are available for API keys and issued tokens - subscribe := can subscribe to messages and presence state change messages on channels, and get the presence set of a channel - publish := can publish messages to channels - presence := can register presence on a channel (enter, update and leave) +- object-subscribe := can subscribe to updates to objects on a channel +- object-publish := can update objects on a channel - history := can retrieve message and presence state history on channels - stats := can retrieve current and historical usage statistics for an app - push-subscribe := can subscribe devices for push notifications diff --git a/content/partials/core-features/_authentication_capabilities.textile b/content/partials/core-features/_authentication_capabilities.textile index adcd31561d..9b8f5f06cd 100644 --- a/content/partials/core-features/_authentication_capabilities.textile +++ b/content/partials/core-features/_authentication_capabilities.textile @@ -3,6 +3,8 @@ The following capability operations are available for API keys and issued tokens - subscribe := can subscribe to messages and presence state change messages on channels, and get the presence set of a channel - publish := can publish messages to channels - presence := can register presence on a channel (enter, update and leave) +- object-subscribe := can subscribe to updates to objects on a channel +- object-publish := can update objects on a channel - history := can retrieve message and presence state history on channels - stats := can retrieve current and historical usage statistics for an app - push-subscribe := can subscribe devices for push notifications From b5bcea6c0388f881e5b9bd16f2ddae6e31a6ce3d Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Mon, 31 Mar 2025 14:38:21 +0100 Subject: [PATCH 05/38] liveobjects: max objects limit --- content/pricing/limits.textile | 1 + 1 file changed, 1 insertion(+) diff --git a/content/pricing/limits.textile b/content/pricing/limits.textile index 8878543173..acae705134 100644 --- a/content/pricing/limits.textile +++ b/content/pricing/limits.textile @@ -82,6 +82,7 @@ Channel limits relate to the number, rate and membership of "channels":/docs/cha | *Message publish rate per channel (per second)*

_the maximum rate at which messages can be published for each channel_

| 50 | 50 | 50 | 50 | | *Presence members per channel*

_the maximum number of clients that can be simultaneously present on a channel_

| 200 | 200 | 200 | 200 | | *Presence members per channel with "server-side batching":/docs/messages/batch#server-side enabled*

_the maximum number of clients that can be simultaneously present on a channel when server-side batching is enabled_

| 200 | 5,000 | 10,000 | 20,000 | +| *Objects per channel*

_the maximum number of objects that can be stored on a channel_

| 100 | 100 | 100 | 100 | h2(#connection). Connection limits From f8f95d94e966e2d9b18397d63fa4dcf0647e1de0 Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Mon, 31 Mar 2025 14:41:47 +0100 Subject: [PATCH 06/38] stats: rename state -> objects - Rename stat type state -> object - Add missing all objects category - Rename state message -> object message --- content/metadata-stats/stats.textile | 170 ++++++++++++++------------- 1 file changed, 87 insertions(+), 83 deletions(-) diff --git a/content/metadata-stats/stats.textile b/content/metadata-stats/stats.textile index 8255e22353..70e2b205d6 100644 --- a/content/metadata-stats/stats.textile +++ b/content/metadata-stats/stats.textile @@ -385,18 +385,22 @@ The following metrics can be returned for all messages. 'All messages' includes | messages.all.all.uncompressedData | Total uncompressed message size, excluding delta compression. | | messages.all.all.failed | Total number of messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as they were rejected by an external integration target, or a service issue on Ably's side. | | messages.all.all.refused | Total number of messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions.| -| messages.all.messages.count | Total message count, excluding presence and state messages. | -| messages.all.messages.billableCount | Total billable message count, excluding presence and state messages. | -| messages.all.messages.data | Total message size, excluding presence and state messages. | +| messages.all.messages.count | Total message count, excluding presence and object messages. | +| messages.all.messages.billableCount | Total billable message count, excluding presence and object messages. | +| messages.all.messages.data | Total message size, excluding presence and object messages. | | messages.all.messages.uncompressedData | Total number of messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as they were rejected by an external integration target, or a service issue on Ably's side. | -| messages.all.messages.failed | Total number of messages excluding presence and state messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as they were rejected by an external integration target, or a service issue on Ably's side. | -| messages.all.messages.refused | Total number of messages excluding presence and state messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | +| messages.all.messages.failed | Total number of messages excluding presence and object messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as they were rejected by an external integration target, or a service issue on Ably's side. | +| messages.all.messages.refused | Total number of messages excluding presence and object messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | | messages.all.presence.count | Total presence message count. | | messages.all.presence.billableCount | Total billable presence message count. | | messages.all.presence.data | Total presence message size. | | messages.all.presence.uncompressedData | Total uncompressed presence message size, excluding delta compression. | -| messages.all.messages.failed | Total number of presence messages excluding presence and state messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as they were rejected by an external integration target, or a service issue on Ably's side. | -| messages.all.messages.refused | Total number of presence messages excluding presence and state messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | +| messages.all.objects.count | Total objects message count. | +| messages.all.objects.billableCount | Total billable objects message count. | +| messages.all.objects.data | Total objects message size. | +| messages.all.objects.uncompressedData | Total uncompressed objects message size, excluding delta compression. | +| messages.all.messages.failed | Total number of presence messages excluding presence and object messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as they were rejected by an external integration target, or a service issue on Ably's side. | +| messages.all.messages.refused | Total number of presence messages excluding presence and object messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | h3(#inbound). Inbound messages @@ -408,50 +412,50 @@ The following metrics can be returned for inbound messages. Inbound messages are | messages.inbound.realtime.all.uncompressedData | Total uncompressed inbound realtime message size, excluding delta compression, received by Ably from clients. | | messages.inbound.realtime.all.failed | Total number of inbound realtime messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | | messages.inbound.realtime.all.refused | Total number of inbound realtime messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | -| messages.inbound.realtime.messages.count | Total inbound realtime message count, excluding presence and state messages, received by Ably from clients. | -| messages.inbound.realtime.messages.data | Total inbound realtime message size, excluding presence and state messages, received by Ably from clients. | -| messages.inbound.realtime.messages.uncompressedData | Total uncompressed inbound realtime message size, received by Ably from clients. This excludes delta compression, and presence and state messages. | -| messages.inbound.realtime.messages.failed | Total number of inbound realtime messages excluding presence and state messages that failed These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as they were rejected by an external integration target, or a service issue on Ably's side. | -| messages.inbound.realtime.messages.refused | Total number of inbound realtime messages excluding presence and state messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions.| +| messages.inbound.realtime.messages.count | Total inbound realtime message count, excluding presence and object messages, received by Ably from clients. | +| messages.inbound.realtime.messages.data | Total inbound realtime message size, excluding presence and object messages, received by Ably from clients. | +| messages.inbound.realtime.messages.uncompressedData | Total uncompressed inbound realtime message size, received by Ably from clients. This excludes delta compression, and presence and object messages. | +| messages.inbound.realtime.messages.failed | Total number of inbound realtime messages excluding presence and object messages that failed These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as they were rejected by an external integration target, or a service issue on Ably's side. | +| messages.inbound.realtime.messages.refused | Total number of inbound realtime messages excluding presence and object messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions.| | messages.inbound.realtime.presence.count | Total inbound realtime presence message count, received by Ably from clients. | | messages.inbound.realtime.presence.data | Total inbound realtime presence message size, received by Ably from clients. | | messages.inbound.realtime.presence.uncompressedData | Total uncompressed inbound realtime presence message size, excluding delta compression, received by Ably from clients. | | messages.inbound.realtime.presence.failed | Total number of inbound realtime presence messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | | messages.inbound.realtime.presence.refused | Total number of inbound realtime presence messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | -| messages.inbound.realtime.state.count | Total inbound realtime state message count, received by Ably from clients. | -| messages.inbound.realtime.state.data | Total inbound realtime state message size, received by Ably from clients. | -| messages.inbound.realtime.state.uncompressedData | Total uncompressed inbound realtime state message size received by Ably from clients. | -| messages.inbound.realtime.state.failed | Total number of inbound realtime state messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | -| messages.inbound.realtime.state.refused | Total number of inbound realtime state messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | +| messages.inbound.realtime.objects.count | Total inbound realtime object message count, received by Ably from clients. | +| messages.inbound.realtime.objects.data | Total inbound realtime object message size, received by Ably from clients. | +| messages.inbound.realtime.objects.uncompressedData | Total uncompressed inbound realtime object message size received by Ably from clients. | +| messages.inbound.realtime.objects.failed | Total number of inbound realtime object messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | +| messages.inbound.realtime.objects.refused | Total number of inbound realtime object messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | | messages.inbound.rest.all.count | Total inbound REST message count, received by Ably from clients. | | messages.inbound.rest.all.data | Total inbound REST message size, received by Ably from clients. | | messages.inbound.rest.all.uncompressedData | Total uncompressed inbound REST message size, excluding delta compression, received by Ably from clients. | | messages.inbound.rest.all.failed | Total number of inbound REST messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | | messages.inbound.rest.all.refused | Total number of inbound REST messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | -| messages.inbound.rest.messages.count | Total inbound REST message count, excluding presence and state messages, received by Ably from clients. | -| messages.inbound.rest.messages.data | Total inbound REST message size, excluding presence and state messages, received by Ably from clients. | -| messages.inbound.rest.messages.uncompressedData | Total uncompressed inbound REST message size, received by Ably from clients. This excludes delta compression, and presence and state messages. | -| messages.inbound.rest.messages.failed | Total number of inbound REST messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. This excludes presence and state messages. | -| messages.inbound.rest.messages.refused | Total number of inbound REST messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. This excludes presence and state messages. | +| messages.inbound.rest.messages.count | Total inbound REST message count, excluding presence and object messages, received by Ably from clients. | +| messages.inbound.rest.messages.data | Total inbound REST message size, excluding presence and object messages, received by Ably from clients. | +| messages.inbound.rest.messages.uncompressedData | Total uncompressed inbound REST message size, received by Ably from clients. This excludes delta compression, and presence and object messages. | +| messages.inbound.rest.messages.failed | Total number of inbound REST messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. This excludes presence and object messages. | +| messages.inbound.rest.messages.refused | Total number of inbound REST messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. This excludes presence and object messages. | | messages.inbound.rest.presence.count | Total inbound REST presence message count, received by Ably from clients. | | messages.inbound.rest.presence.data | Total inbound REST presence message size, received by Ably from clients. | | messages.inbound.rest.presence.uncompressedData | Total uncompressed inbound REST presence message size, excluding delta compression, received by Ably from clients. | | messages.inbound.rest.presence.failed | Total number of inbound REST presence messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | | messages.inbound.rest.presence.refused | Total number of inbound REST presence messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions.| -| messages.inbound.rest.state.count | Total inbound REST state message count, received by Ably from clients. | -| messages.inbound.rest.state.data | Total inbound REST state message size, received by Ably from clients. | -| messages.inbound.rest.state.uncompressedData | Total uncompressed inbound REST state message size, excluding delta compression received by Ably from clients. | -| messages.inbound.rest.state.failed | Total number of inbound REST state messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | -| messages.inbound.rest.state.refused | Total number of inbound REST state messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions.| +| messages.inbound.rest.objects.count | Total inbound REST object message count, received by Ably from clients. | +| messages.inbound.rest.objects.data | Total inbound REST object message size, received by Ably from clients. | +| messages.inbound.rest.objects.uncompressedData | Total uncompressed inbound REST object message size, excluding delta compression received by Ably from clients. | +| messages.inbound.rest.objects.failed | Total number of inbound REST object messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | +| messages.inbound.rest.objects.refused | Total number of inbound REST object messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions.| | messages.inbound.all.all.count | Total inbound message count, received by Ably from clients. | | messages.inbound.all.all.data | Total inbound message size, received by Ably from clients. | | messages.inbound.all.all.uncompressedData | Total uncompressed inbound message size, excluding delta compression, received by Ably from clients. | | messages.inbound.all.all.failed | Total number of inbound messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | | messages.inbound.all.all.refused | Total number of inbound messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits, malformed messages, or incorrect client permissions. | -| messages.inbound.all.messages.count | Total inbound message count, excluding presence and state messages, received by Ably from clients. | -| messages.inbound.all.messages.data | Total inbound message size, excluding presence and state messages, received by Ably from clients. | -| messages.inbound.all.messages.uncompressedData | Total uncompressed inbound message size, received by Ably from clients. This excludes delta compression, and presence and state messages. | -| messages.inbound.all.messages.failed | Total number of inbound messages excluding presence and state messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | +| messages.inbound.all.messages.count | Total inbound message count, excluding presence and object messages, received by Ably from clients. | +| messages.inbound.all.messages.data | Total inbound message size, excluding presence and object messages, received by Ably from clients. | +| messages.inbound.all.messages.uncompressedData | Total uncompressed inbound message size, received by Ably from clients. This excludes delta compression, and presence and object messages. | +| messages.inbound.all.messages.failed | Total number of inbound messages excluding presence and object messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | | messages.inbound.all.presence.count | Total inbound presence message count, received by Ably from clients. | | messages.inbound.all.presence.data | Total inbound presence message size, received by Ably from clients. | | messages.inbound.all.presence.uncompressedData | Total uncompressed inbound presence message size, excluding delta compression, received by Ably from clients. | @@ -469,47 +473,47 @@ The following metrics can be returned for outbound messages. Outbound messages a | messages.outbound.realtime.all.uncompressedData | Total uncompressed outbound realtime message size, excluding delta compression, sent from Ably to clients. | | messages.outbound.realtime.all.failed | Total number of outbound realtime messages that failed. These are messages which did not succeed for some reason other than Ably rejecting them, such as rejection by an external integration target. | | messages.outbound.realtime.all.refused | Total number of outbound realtime messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | -| messages.outbound.realtime.messages.count | Total outbound realtime message count, excluding presence and state messages, sent from Ably to clients. | -| messages.outbound.realtime.messages.billableCount | Total billable outbound realtime message count, excluding presence and state messages, sent from Ably to clients. | -| messages.outbound.realtime.messages.data | Total outbound realtime message size, excluding presence and state messages, sent from Ably to clients. | -| messages.outbound.realtime.messages.uncompressedData | Total uncompressed outbound realtime message size, sent from Ably to clients. This excludes delta compression, and presence and state messages. | -| messages.outbound.realtime.messages.failed | Total number of outbound realtime messages excluding presence and state messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | -| messages.outbound.realtime.messages.refused | Total number of outbound realtime messages excluding presence and state messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | +| messages.outbound.realtime.messages.count | Total outbound realtime message count, excluding presence and object messages, sent from Ably to clients. | +| messages.outbound.realtime.messages.billableCount | Total billable outbound realtime message count, excluding presence and object messages, sent from Ably to clients. | +| messages.outbound.realtime.messages.data | Total outbound realtime message size, excluding presence and object messages, sent from Ably to clients. | +| messages.outbound.realtime.messages.uncompressedData | Total uncompressed outbound realtime message size, sent from Ably to clients. This excludes delta compression, and presence and object messages. | +| messages.outbound.realtime.messages.failed | Total number of outbound realtime messages excluding presence and object messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | +| messages.outbound.realtime.messages.refused | Total number of outbound realtime messages excluding presence and object messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | | messages.outbound.realtime.presence.count | Total outbound realtime presence message count, sent from Ably to clients. | | messages.outbound.realtime.presence.billableCount | Total billable outbound realtime presence message count, sent from Ably to clients. | | messages.outbound.realtime.presence.data | Total outbound realtime presence message size, sent from Ably to clients. | | messages.outbound.realtime.presence.uncompressedData | Total uncompressed outbound realtime presence message size, excluding delta compression, sent from Ably to clients. | | messages.outbound.realtime.presence.failed | Total number of outbound realtime presence messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as a service issue on Ably's side. | | messages.outbound.realtime.presence.refused | Total number of outbound realtime presence messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | -| messages.outbound.realtime.state.count | Total outbound realtime state message count, sent from Ably to clients. | -| messages.outbound.realtime.state.billableCount | Total billable outbound realtime state message count, sent from Ably to clients. | -| messages.outbound.realtime.state.data | Total outbound realtime state message size, sent from Ably to clients. | -| messages.outbound.realtime.state.uncompressedData | Total uncompressed outbound realtime presence message size, sent from Ably to clients. | +| messages.outbound.realtime.objects.count | Total outbound realtime object message count, sent from Ably to clients. | +| messages.outbound.realtime.objects.billableCount | Total billable outbound realtime object message count, sent from Ably to clients. | +| messages.outbound.realtime.objects.data | Total outbound realtime object message size, sent from Ably to clients. | +| messages.outbound.realtime.objects.uncompressedData | Total uncompressed outbound realtime presence message size, sent from Ably to clients. | | messages.outbound.rest.all.count | Total outbound REST message count, sent from Ably to clients. | | messages.outbound.rest.all.data | Total outbound REST message size, sent from Ably to clients. | | messages.outbound.rest.all.uncompressedData | Total uncompressed outbound REST message size, excluding delta compression, sent from Ably to clients. | | messages.outbound.rest.all.refused | Total number of messages that would have been broadcast to realtime subscribers as a result of a REST publish attempt that was refused for breaching account-wide "message rate limits":/docs/pricing/limits. | -| messages.outbound.rest.messages.count | Total outbound REST message count, excluding presence and state messages, sent from Ably to clients. | -| messages.outbound.rest.messages.data | Total outbound REST message size, excluding presence and state messages, sent from Ably to clients. | -| messages.outbound.rest.messages.uncompressedData | Total uncompressed outbound REST message size, sent from Ably to clients. This excludes delta compression, and presence and state messages. | -| messages.outbound.rest.messages.refused | Total number of messages that would have been broadcast to realtime subscribers as a result of a REST publish attempt that was refused for breaching account-wide "message rate limit":/docs/pricing/limits. This excludes presence and state messages. | +| messages.outbound.rest.messages.count | Total outbound REST message count, excluding presence and object messages, sent from Ably to clients. | +| messages.outbound.rest.messages.data | Total outbound REST message size, excluding presence and object messages, sent from Ably to clients. | +| messages.outbound.rest.messages.uncompressedData | Total uncompressed outbound REST message size, sent from Ably to clients. This excludes delta compression, and presence and object messages. | +| messages.outbound.rest.messages.refused | Total number of messages that would have been broadcast to realtime subscribers as a result of a REST publish attempt that was refused for breaching account-wide "message rate limit":/docs/pricing/limits. This excludes presence and object messages. | | messages.outbound.rest.presence.count | Total outbound REST presence message count, sent from Ably to clients. | | messages.outbound.rest.presence.data | Total outbound REST presence message size, sent from Ably to clients. | | messages.outbound.rest.presence.uncompressedData | Total uncompressed outbound REST presence message size, excluding delta compression, sent from Ably to clients. | -| messages.outbound.rest.state.count | Total outbound REST state message count, sent from Ably to clients. | -| messages.outbound.rest.state.data | Total outbound REST state message size, sent from Ably to clients. | -| messages.outbound.rest.state.uncompressedData | Total uncompressed outbound REST state message size, excluding delta compression, sent from Ably to clients. | -| messages.outbound.rest.state.refused | Total number of state messages that would have been broadcast to realtime subscribers as a result of a REST publish attempt that was refused for breaching account-wide "message rate limit":/docs/pricing/limits. | +| messages.outbound.rest.objects.count | Total outbound REST object message count, sent from Ably to clients. | +| messages.outbound.rest.objects.data | Total outbound REST object message size, sent from Ably to clients. | +| messages.outbound.rest.objects.uncompressedData | Total uncompressed outbound REST object message size, excluding delta compression, sent from Ably to clients. | +| messages.outbound.rest.objects.refused | Total number of object messages that would have been broadcast to realtime subscribers as a result of a REST publish attempt that was refused for breaching account-wide "message rate limit":/docs/pricing/limits. | | messages.outbound.webhook.all.count | Total outbound webhook message count, sent from Ably to clients using webhooks. | | messages.outbound.webhook.all.data | Total outbound webhook message size, sent from Ably to clients using webhooks. | | messages.outbound.webhook.all.uncompressedData | Total uncompressed outbound webhook message size, excluding delta compression, sent from Ably to clients using webhooks. | | messages.outbound.webhook.all.failed | Total number of outbound webhook messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as rejection by an external integration target. | | messages.outbound.webhook.all.refused | Total number of outbound webhook messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | -| messages.outbound.webhook.messages.count | Total outbound webhook message count, sent from Ably to clients using webhooks. This excludes presence and state messages.| -| messages.outbound.webhook.messages.data | Total outbound webhook message size, sent from Ably to clients using webhooks. This excludes presence and state messages. | -| messages.outbound.webhook.messages.uncompressedData | Total uncompressed outbound webhook message size, sent from Ably to clients using webhooks. This excludes delta compression, and presence and state messages. | -| messages.outbound.webhook.messages.failed | Total number of outbound webhook messages excluding presence and state messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them such as rejection by an external integration target. | -| messages.outbound.webhook.messages.refused | Total number of outbound webhook messages excluding presence and state messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | +| messages.outbound.webhook.messages.count | Total outbound webhook message count, sent from Ably to clients using webhooks. This excludes presence and object messages.| +| messages.outbound.webhook.messages.data | Total outbound webhook message size, sent from Ably to clients using webhooks. This excludes presence and object messages. | +| messages.outbound.webhook.messages.uncompressedData | Total uncompressed outbound webhook message size, sent from Ably to clients using webhooks. This excludes delta compression, and presence and object messages. | +| messages.outbound.webhook.messages.failed | Total number of outbound webhook messages excluding presence and object messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them such as rejection by an external integration target. | +| messages.outbound.webhook.messages.refused | Total number of outbound webhook messages excluding presence and object messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | | messages.outbound.webhook.presence.count | Total outbound webhook presence message count, sent from Ably to clients using webhooks. | | messages.outbound.webhook.presence.data | Total outbound webhook presence message size, sent from Ably to clients using webhooks. | | messages.outbound.webhook.presence.uncompressedData | Total uncompressed outbound webhook presence message size, excluding delta compression, sent from Ably to clients using webhooks. | @@ -520,11 +524,11 @@ The following metrics can be returned for outbound messages. Outbound messages a | messages.outbound.sharedQueue.all.uncompressedData | Total uncompressed Ably Queue message size, excluding delta compression, sent from Ably to an Ably Queue using an integration rule. | | messages.outbound.sharedQueue.all.failed | Total number of Ably Queue messages that failed because they were rejected by RabbitMQ for some reason. | | messages.outbound.sharedQueue.all.refused | Total number of Ably Queue messages that Ably refused to send. This is generally due to "rate limiting":/docs/pricing/limits. | -| messages.outbound.sharedQueue.messages.count | Total Ably Queue message count, sent from Ably to an Ably Queue using an integration rule. This excludes presence and state messages. | -| messages.outbound.sharedQueue.messages.data | Total Ably Queue message size, sent from Ably to an Ably Queue using an integration rule. This excludes presence and state messages. | -| messages.outbound.sharedQueue.messages.uncompressedData | Total uncompressed Ably Queue message size, sent from Ably to an Ably Queue using an integration rule. This excludes delta compression, and presence and state messages. | -| messages.outbound.sharedQueue.messages.failed | Total number of Ably Queue messages, excluding presence and state messages, that failed because they were rejected by RabbitMQ for some reason. | -| messages.outbound.sharedQueue.messages.refused | Total number of Ably Queue messages, excluding presence and state messages, that Ably refused to send. | +| messages.outbound.sharedQueue.messages.count | Total Ably Queue message count, sent from Ably to an Ably Queue using an integration rule. This excludes presence and object messages. | +| messages.outbound.sharedQueue.messages.data | Total Ably Queue message size, sent from Ably to an Ably Queue using an integration rule. This excludes presence and object messages. | +| messages.outbound.sharedQueue.messages.uncompressedData | Total uncompressed Ably Queue message size, sent from Ably to an Ably Queue using an integration rule. This excludes delta compression, and presence and object messages. | +| messages.outbound.sharedQueue.messages.failed | Total number of Ably Queue messages, excluding presence and object messages, that failed because they were rejected by RabbitMQ for some reason. | +| messages.outbound.sharedQueue.messages.refused | Total number of Ably Queue messages, excluding presence and object messages, that Ably refused to send. | | messages.outbound.sharedQueue.presence.count | Total Ably Queue presence message count, sent from Ably to an Ably Queue using an integration rule. | | messages.outbound.sharedQueue.presence.data | Total Ably Queue presence message size, sent from Ably to an Ably Queue using an integration rule. | | messages.outbound.sharedQueue.presence.uncompressedData | Total uncompressed Ably Queue presence message size, excluding delta compression, sent from Ably to an Ably Queue using an integration rule. | @@ -535,11 +539,11 @@ The following metrics can be returned for outbound messages. Outbound messages a | messages.outbound.externalQueue.all.uncompressedData | Total uncompressed Firehose message size, excluding delta compression, sent from Ably to an external target using a Firehose integration rule. | | messages.outbound.externalQueue.all.failed | Total number of Firehose messages that failed because they were rejected by the external integration target for some reason. | | messages.outbound.externalQueue.all.refused | Total number of Firehose messages that Ably refused to send. This is generally due to "rate limiting":/docs/pricing/limits. | -| messages.outbound.externalQueue.messages.count | Total Firehose message count, sent from Ably to an external target using a Firehose integration rule. This excludes presence and state messages. | -| messages.outbound.externalQueue.messages.data | Total Firehose message size, sent from Ably to an external target using a Firehose integration rule. This excludes presence and state messages. | -| messages.outbound.externalQueue.messages.uncompressedData | Total uncompressed Firehose message size, sent from Ably to an external target using a Firehose integration rule. This excludes delta compression, and presence and state messages. | -| messages.outbound.externalQueue.messages.failed | Total number of Firehose messages, excluding presence and state messages, that failed because they were rejected by the external integration target for some reason. | -| messages.outbound.externalQueue.messages.refused | Total number of Firehose messages, excluding presence and state messages, that Ably refused to send. This is generally due to "rate limiting":/docs/pricing/limits. | +| messages.outbound.externalQueue.messages.count | Total Firehose message count, sent from Ably to an external target using a Firehose integration rule. This excludes presence and object messages. | +| messages.outbound.externalQueue.messages.data | Total Firehose message size, sent from Ably to an external target using a Firehose integration rule. This excludes presence and object messages. | +| messages.outbound.externalQueue.messages.uncompressedData | Total uncompressed Firehose message size, sent from Ably to an external target using a Firehose integration rule. This excludes delta compression, and presence and object messages. | +| messages.outbound.externalQueue.messages.failed | Total number of Firehose messages, excluding presence and object messages, that failed because they were rejected by the external integration target for some reason. | +| messages.outbound.externalQueue.messages.refused | Total number of Firehose messages, excluding presence and object messages, that Ably refused to send. This is generally due to "rate limiting":/docs/pricing/limits. | | messages.outbound.externalQueue.presence.count | Total Firehose presence message count, sent from Ably to an external target using a Firehose integration rule. | | messages.outbound.externalQueue.presence.data | Total Firehose presence message size, sent from Ably to an external target using a Firehose integration rule. | | messages.outbound.externalQueue.presence.uncompressedData | Total uncompressed Firehose presence message size, sent from Ably to an external target using a Firehose integration rule. This excludes delta compression. | @@ -550,11 +554,11 @@ The following metrics can be returned for outbound messages. Outbound messages a | messages.outbound.httpEvent.all.uncompressedData | Total uncompressed size of messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. This excludes delta compression. | | messages.outbound.httpEvent.all.failed | Total number of messages sent by a HTTP trigger that failed, because they were rejected by the external endpoint for some reason. | | messages.outbound.httpEvent.all.refused | Total number of messages sent by a HTTP trigger that Ably refused to send. This is generally due to "rate limiting":/docs/pricing/limits. | -| messages.outbound.httpEvent.messages.count | Total messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. This excludes presence and state messages. | -| messages.outbound.httpEvent.messages.data | Total size of messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. This excludes presence and state messages. | -| messages.outbound.httpEvent.messages.uncompressedData | Total uncompressed size of messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. This excludes delta compression, and presence and state messages. | -| messages.outbound.httpEvent.messages.failed | Total number of messages sent by a HTTP trigger, excluding presence and state messages, that failed because they were rejected by the external endpoint for some reason. | -| messages.outbound.httpEvent.messages.refused | Total number of messages sent by a HTTP trigger, excluding presence and state messages, that Ably refused to send. This is generally due to "rate limiting":/docs/pricing/limits. | +| messages.outbound.httpEvent.messages.count | Total messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. This excludes presence and object messages. | +| messages.outbound.httpEvent.messages.data | Total size of messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. This excludes presence and object messages. | +| messages.outbound.httpEvent.messages.uncompressedData | Total uncompressed size of messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. This excludes delta compression, and presence and object messages. | +| messages.outbound.httpEvent.messages.failed | Total number of messages sent by a HTTP trigger, excluding presence and object messages, that failed because they were rejected by the external endpoint for some reason. | +| messages.outbound.httpEvent.messages.refused | Total number of messages sent by a HTTP trigger, excluding presence and object messages, that Ably refused to send. This is generally due to "rate limiting":/docs/pricing/limits. | | messages.outbound.httpEvent.presence.count | Total presence messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. | | messages.outbound.httpEvent.presence.data | Total size of presence messages sent by a HTTP trigger. Typically a serverless function on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. | | messages.outbound.httpEvent.presence.uncompressedData | Total uncompressed size of presence messages sent by a HTTP trigger. Typically serverless functions on a service such as AWS Lambda, Google Cloud Functions, or Azure Functions. This excludes delta compression. | @@ -565,11 +569,11 @@ The following metrics can be returned for outbound messages. Outbound messages a | messages.outbound.push.all.uncompressedData | Total uncompressed push message size, excluding delta compression, pushed to devices via a push notifications transport such as FCM or APNS. | | messages.outbound.push.all.failed | Total number of push messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as rejection by APNS or FCM, or a service issue on Ably's side. | | messages.outbound.push.all.refused | Total number of push messages that were refused by Ably. For example, due to "rate limiting":/docs/pricing/limits. | -| messages.outbound.push.messages.count | Total push message count, excluding delta compression, and presence and state messages, pushed to devices via a push notifications transport such as FCM or APNS.| -| messages.outbound.push.messages.data | Total push message size, excluding delta compression, and presence and state messages, pushed to devices via a push notifications transport such as FCM or APNS. | -| messages.outbound.push.messages.uncompressedData | Total uncompressed push message size, excluding delta compression, and presence and state messages, pushed to devices via a push notifications transport such as FCM or APNS. | -| messages.outbound.push.messages.failed | Total number of push messages, excluding presence and state messages, that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as rejection by APNS or FCM, or a service issue on Ably's side. | -| messages.outbound.push.messages.refused | Total number of push messages, excluding presence and state messages, that were refused by Ably. For example due to "rate limiting":/docs/pricing/limits. | +| messages.outbound.push.messages.count | Total push message count, excluding delta compression, and presence and object messages, pushed to devices via a push notifications transport such as FCM or APNS.| +| messages.outbound.push.messages.data | Total push message size, excluding delta compression, and presence and object messages, pushed to devices via a push notifications transport such as FCM or APNS. | +| messages.outbound.push.messages.uncompressedData | Total uncompressed push message size, excluding delta compression, and presence and object messages, pushed to devices via a push notifications transport such as FCM or APNS. | +| messages.outbound.push.messages.failed | Total number of push messages, excluding presence and object messages, that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as rejection by APNS or FCM, or a service issue on Ably's side. | +| messages.outbound.push.messages.refused | Total number of push messages, excluding presence and object messages, that were refused by Ably. For example due to "rate limiting":/docs/pricing/limits. | | messages.outbound.push.presence.count | Total push presence message count, sent to devices via a push notifications transport such as FCM or APNS. | | messages.outbound.push.presence.data | Total push presence message size, sent to devices via a push notifications transport such as FCM or APNS. | | messages.outbound.push.presence.uncompressedData | Total uncompressed push presence message size, excluding delta compression, sent to devices via a push notifications transport such as FCM or APNS. | @@ -581,12 +585,12 @@ The following metrics can be returned for outbound messages. Outbound messages a | messages.outbound.all.all.uncompressedData | Total uncompressed outbound message size, excluding delta compression, sent from Ably to clients. | | messages.outbound.all.all.failed | Total number of outbound messages that failed. These are messages which not succeed for some reason other than Ably explicitly refusing them, such as rejection by an external integration target, or a service issue on Ably's side. | | messages.outbound.all.all.refused | Total number of outbound messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | -| messages.outbound.all.messages.count | Total outbound message count, excluding presence and state messages, sent from Ably to clients. | -| messages.outbound.all.messages.billableCount | Total billable outbound message count, excluding presence and state messages, sent from Ably to clients. | -| messages.outbound.all.messages.data | Total outbound message size, excluding presence and state messages, sent from Ably to clients. | -| messages.outbound.all.messages.uncompressedData | Total uncompressed outbound message size, excluding delta compression, and presence and state messages, sent from Ably to clients. | -| messages.outbound.all.messages.failed | Total number of outbound messages excluding presence and state messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as rejection by an external integration target, or a service issue on Ably's side. | -| messages.outbound.all.messages.refused | Total number of outbound messages excluding presence and state messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | +| messages.outbound.all.messages.count | Total outbound message count, excluding presence and object messages, sent from Ably to clients. | +| messages.outbound.all.messages.billableCount | Total billable outbound message count, excluding presence and object messages, sent from Ably to clients. | +| messages.outbound.all.messages.data | Total outbound message size, excluding presence and object messages, sent from Ably to clients. | +| messages.outbound.all.messages.uncompressedData | Total uncompressed outbound message size, excluding delta compression, and presence and object messages, sent from Ably to clients. | +| messages.outbound.all.messages.failed | Total number of outbound messages excluding presence and object messages that failed. These are messages which did not succeed for some reason other than Ably explicitly refusing them, such as rejection by an external integration target, or a service issue on Ably's side. | +| messages.outbound.all.messages.refused | Total number of outbound messages excluding presence and object messages that were refused by Ably. This is generally due to "rate limiting":/docs/pricing/limits. | | messages.outbound.all.presence.count | Total outbound presence message count, sent from Ably to clients. | | messages.outbound.all.presence.billableCount | Total billable outbound presence message count, sent from Ably to clients. | | messages.outbound.all.presence.data | Total outbound presence message size, sent from Ably to clients. | @@ -602,9 +606,9 @@ The following metrics can be returned for persisted messages. Persisted messages | messages.persisted.all.count | Total count of persisted messages. | | messages.persisted.all.data | Total size of persisted messages. | | messages.persisted.all.uncompressedData | Total uncompressed persisted message size, excluding delta compression. | -| messages.persisted.messages.count | Total count of persisted messages, excluding presence and state messages. | -| messages.persisted.messages.data | Total size of persisted messages, excluding presence and state messages. | -| messages.persisted.messages.uncompressedData | Total uncompressed persisted message size, excluding delta compression, and presence and state messages. | +| messages.persisted.messages.count | Total count of persisted messages, excluding presence and object messages. | +| messages.persisted.messages.data | Total size of persisted messages, excluding presence and object messages. | +| messages.persisted.messages.uncompressedData | Total uncompressed persisted message size, excluding delta compression, and presence and object messages. | | messages.persisted.presence.count | Total count of persisted presence messages. | | messages.persisted.presence.data | Total size of persisted presence messages. | | messages.persisted.presence.uncompressedData | Total uncompressed persisted presence message size, excluding delta compression. | From 4ed10833e87f60c6511bb2007a7da0abe6f5670c Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Mon, 31 Mar 2025 14:50:55 +0100 Subject: [PATCH 07/38] liveobjects: objects message excluded from storage /docs/liveobjects added in https://github.com/ably/docs/pull/2464 --- content/channels/options/rewind.textile | 2 +- content/storage-history/storage.textile | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/content/channels/options/rewind.textile b/content/channels/options/rewind.textile index 6fd1c3f107..4351fd9d05 100644 --- a/content/channels/options/rewind.textile +++ b/content/channels/options/rewind.textile @@ -41,7 +41,7 @@ If the attachment is successful, and one or more messages exist on the channel p Any @rewind@ value that cannot be parsed either as a number or a time specifier will cause the attachment request to fail and return an error. The following example subscribes to a channel and retrieves the most recent message sent on it, if available: diff --git a/content/storage-history/storage.textile b/content/storage-history/storage.textile index 5977136484..8e742ad12b 100644 --- a/content/storage-history/storage.textile +++ b/content/storage-history/storage.textile @@ -17,7 +17,9 @@ The following diagram illustrates the default persistence of messages: h2(#all-message-persistence). Persist all messages -If you need to retain messages for longer than the default two minutes you can enable persisted history by setting a "channel rule":/docs/channels#rules. When persisted history is enabled for a channel any messages will be stored on disk. The time that messages will be stored for depends on your account package: +If you need to retain messages for longer than the default two minutes you can enable persisted history by setting a "channel rule":/docs/channels#rules. When persisted history is enabled for a channel any messages will be stored on disk. Note that this does not apply to "object messages":/docs/liveobjects. + +The time that messages will be stored for depends on your account package: |_. Package |_. Minimum |_. Maximum | | Free | 24 hours | 24 hours | @@ -36,7 +38,7 @@ Note that every message that is persisted to, or retrieved from, disk counts as h2(#persist-last-message). Persist last message - 365 days -You can persist just the last message sent to a channel for one year by setting a "channel rule":/docs/channels#rules. Note that this does not apply to "presence messages":/docs/presence-occupancy/presence. +You can persist just the last message sent to a channel for one year by setting a "channel rule":/docs/channels#rules. Note that this does not apply to "presence messages":/docs/presence-occupancy/presence or "object messages":/docs/liveobjects. Messages persisted for a year can be retrieved using the "rewind channel option":/docs/channels/options/rewind, or from the REST history API using "certain parameters":/docs/storage-history/history#channel-parameters. From 0c431afa524e791418600c3b5bff397c917aedc3 Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Mon, 31 Mar 2025 15:03:38 +0100 Subject: [PATCH 08/38] liveobjects: add object modes --- content/channels/options/index.textile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/channels/options/index.textile b/content/channels/options/index.textile index 4d6349735e..4c91ee5a42 100644 --- a/content/channels/options/index.textile +++ b/content/channels/options/index.textile @@ -415,6 +415,8 @@ The channel mode flags available are: | PUBLISH | Can publish messages to the channel. | | PRESENCE_SUBSCRIBE | Can subscribe to receive presence events on the channel. | | PRESENCE | Can register presence on the channel. | +| OBJECT_PUBLISH | Can update objects on the channel. | +| OBJECT_SUBSCRIBE | Can subscribe to receive updates to objects on the channel. | The following is an example of setting channel mode flags: From 268c09843c99d62270fc950f2e9971dec7d8f5da Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Wed, 2 Apr 2025 11:30:02 +0100 Subject: [PATCH 09/38] channels/options: channel modes docs Update the channel modes docs to clarify the distinction between capabilities and modes, which capabilties grant access to which modes, the default modes requested by a client if none are explicitly requested, and how the resulting modes assigned to the client are determined. This change is made now in order to clarify the behaviour of the `OBJECT_*` modes, which are non-default and must be explicitly requested. --- content/channels/options/index.textile | 33 +++++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/content/channels/options/index.textile b/content/channels/options/index.textile index 4c91ee5a42..8d824b3b75 100644 --- a/content/channels/options/index.textile +++ b/content/channels/options/index.textile @@ -406,17 +406,32 @@ Occupancy events have a payload in the @data@ property with a value of @occupanc h2(#modes). Modes -The @modes@ property can be used to set channel mode flags. Channel mode flags enable a client to specify a subset of the "capabilities":/docs/auth/capabilities granted by their token or API key. Channel mode flags offer the ability for clients to use different capabilities for different channels, however, as they are flags and not permissions they cannot be enforced by an authentication server. +Channel mode flags enable a client to specify which functionality they will use on the channel. -The channel mode flags available are: +A client can explicitly request a set of modes using the @modes@ property. If the @modes@ property is not provided, the default modes will be used. -|_. Flag |_. Description | -| SUBSCRIBE | Can subscribe to receive messages on the channel. | -| PUBLISH | Can publish messages to the channel. | -| PRESENCE_SUBSCRIBE | Can subscribe to receive presence events on the channel. | -| PRESENCE | Can register presence on the channel. | -| OBJECT_PUBLISH | Can update objects on the channel. | -| OBJECT_SUBSCRIBE | Can subscribe to receive updates to objects on the channel. | +The available set of channel mode flags are: + +|_. Flag |_. Description |_. Default? | +| @SUBSCRIBE@ | Can subscribe to receive messages on the channel. | Yes | +| @PUBLISH@ | Can publish messages to the channel. | Yes | +| @PRESENCE_SUBSCRIBE@ | Can subscribe to receive presence events on the channel. | Yes | +| @PRESENCE@ | Can register presence on the channel. | Yes | +| @OBJECT_PUBLISH@ | Can update objects on the channel. | No | +| @OBJECT_SUBSCRIBE@ | Can subscribe to receive updates to objects on the channel. | No | + +The set of modes available to a client is determined by the set of "capabilities":/docs/auth/capabilities granted by their token or API key. + +The modes granted by each capability are: + +|_. Capability |_. Granted Modes | +| @subscribe@ | @SUBSCRIBE@, @PRESENCE_SUBSCRIBE@, @OBJECT_SUBSCRIBE@ | +| @publish@ | @PUBLISH@ | +| @presence@ | @PRESENCE@ | +| @object-subscribe@ | @OBJECT_SUBSCRIBE@ | +| @object-publish@ | @OBJECT_PUBLISH@ | + +The actual modes assigned to a client will be the **intersection** of the requested @modes@ and the modes available to the client according to its capabilities. For example, a client with the @subscribe@ capability which explicitly requests @SUBSCRIBE@ and @PUBLISH@ modes will be assigned only the @SUBSCRIBE@ mode. The following is an example of setting channel mode flags: From cc575fd5673830e36de09b4e435dd90da81b759b Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 5 Mar 2025 08:51:06 +0000 Subject: [PATCH 10/38] Add LiveObjects index docs --- content/liveobjects/index.textile | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 content/liveobjects/index.textile diff --git a/content/liveobjects/index.textile b/content/liveobjects/index.textile new file mode 100644 index 0000000000..1b8a5a1c29 --- /dev/null +++ b/content/liveobjects/index.textile @@ -0,0 +1,71 @@ +--- +title: About LiveObjects +meta_description: "Learn about Ably LiveObjects, its features, use cases, and how it simplifies realtime state synchronization." +product: liveobjects +--- + +Ably LiveObjects enables effortless realtime synchronization of application state across multiple users and devices at any scale. LiveObjects provides a set of purpose-built APIs and data structures to handle the complexities of persisting and synchronizing state, freeing you to focus on building features instead of managing concurrency or conflict resolution. + +LiveObjects enables you to store data as "objects" on a channel. These objects are automatically synchronized in realtime across all connected clients, and any conflicts that arise from concurrent updates are seamlessly resolved in the background. + +LiveObjects is managed and persisted on a per-channel basis and benefit from the same performance guarantees and scaling potential as "channels":/docs/channels. + +The LiveObjects API is available as a feature of "channels":/docs/channels within the Ably Pub/Sub SDK and can be accessed via the "@channel.objects@":/docs/api/realtime-sdk/channels#objects property. + +h2(#use-cases). Use cases + +Ably LiveObjects is useful when your application has data that: + +* Is shared by many users +* Needs to be synchronized in realtime +* Can be updated concurrently by many users + +Use Ably LiveObjects to build scalable realtime applications such as: + +* Voting and polling systems: Platforms that need the ability to count and display votes in realtime, such as audience engagement tools, quizzes, and decision-making applications. +* Collaborative applications: Tools like shared whiteboards or content and product management applications where multiple users edit shared content simultaneously. +* Live leaderboards: Multiplayer games or competition-based applications that require up-to-date rankings and scoreboards. +* Game state: Applications that present dynamic in-game statistics or game state in realtime, such as player health, scores, and inventory changes. +* Shared configuration, settings or controls: Systems where configuration parameters are shared or updated across multiple users or devices. + +h2(#features). LiveObjects features + +Ably LiveObjects provides the following key features: + +* "LiveCounter":#counter +* "LiveMap":#map +* "Composability":#composability +* "Batching operations":#batch + +h3(#counter). LiveCounter + +"LiveCounter":/docs/liveobjects/counter is a 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 is ideal for scenarios such as: + +* Tracking reactions (likes, upvotes, or downvotes) in social applications. +* Counting active users in a chatroom. +* Maintaining live leaderboard scores in competitive applications. + +h3(#map). LiveMap + +"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 and buffers, as well as other objects, enabling "composable data structures":#composability. + +Conflicts in a LiveMap are automatically resolved with last-write-wins (LWW) semantics. + +h3(#composability). Composability + +A "LiveMap":/docs/liveobjects/map#composability can store references to other @LiveMap@ or @LiveCounter@ objects as values for its keys, enabling you to build complex, hierarchical object structure. This enables you to represent complex data models in your applications while ensuring realtime synchronization across clients. + +h3(#batch). Batch operations + +"Batching":/docs/liveobjects/batch enables multiple LiveObjects operations to be grouped into a single channel message, ensuring atomic application of grouped operations. This prevents partial updates of your data and ensures consistency across all users. + +Batching is particularly useful in scenarios where multiple dependent updates need to be processed together, ensuring a seamless experience for users. + +h2(#examples). Examples + +Take a look at the LiveObjects examples to help you get started: + +* "Voting system powered by LiveCounter":https://examples.ably.dev/liveobjects-live-counter : Demonstrates how to use a LiveCounter to track votes in a realtime poll. +* "Realtime Task Board powered by LiveMap":https://examples.ably.dev/liveobjects-live-map : Demonstrates how LiveMap can be used to store and manage shared list of tasks between users. From 954587133f603358822004c396255f28e87bbc09 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Wed, 5 Mar 2025 08:51:35 +0000 Subject: [PATCH 11/38] Add `channels.objects` property to realtime api docs --- content/api/realtime-sdk/channels.textile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/content/api/realtime-sdk/channels.textile b/content/api/realtime-sdk/channels.textile index 13753b7d28..02adb6a54e 100644 --- a/content/api/realtime-sdk/channels.textile +++ b/content/api/realtime-sdk/channels.textile @@ -151,6 +151,11 @@ h6(#push). Provides access to the "PushChannel":/docs/api/realtime-sdk/push#push-channel object for this channel which can be used to access members present on the channel, or participate in presence. +h6(#objects). + default: 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. + h3. Methods h6(#publish). From 70e751ff1895f2dd842a3a97ad96933c6b7897a1 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 6 Mar 2025 06:30:47 +0000 Subject: [PATCH 12/38] Add LiveObjects Quickstart docs --- content/liveobjects/quickstart.textile | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 content/liveobjects/quickstart.textile diff --git a/content/liveobjects/quickstart.textile b/content/liveobjects/quickstart.textile new file mode 100644 index 0000000000..dc0680f342 --- /dev/null +++ b/content/liveobjects/quickstart.textile @@ -0,0 +1,200 @@ +--- +title: Quickstart +meta_description: "A quickstart guide to learn the basics of integrating the Ably LiveObjects product into your application." +product: liveobjects +languages: + - javascript +--- + +This guide shows how to integrate Ably LiveObjects into your application. + +You will learn how to: + +* Create an Ably account and get an API key for authentication. +* Install the Ably Pub/Sub SDK. +* Create a channel with LiveObjects functionality enabled. +* Create, update and subscribe to changes on LiveObjects data structures: "LiveMap":/docs/liveobjects/map and "LiveCounter":/docs/liveobjects/counter. + +h2(#step-0). Authentication + +An "API key":/docs/auth#api-keys is required to authenticate with Ably. API keys are used either to authenticate directly with Ably using "basic authentication":/docs/auth/basic, or to generate tokens for untrusted clients using "token authentication":/docs/auth/token. + + + +"Sign up":https://ably.com/sign-up for a free account and create your own API key in the "dashboard":https://ably.com/dashboard or use the "Control API":/docs/account/control-api to create an API key programmatically. + +API keys and tokens have a set of "capabilities":/docs/auth/capabilities assigned to them that specify which operations can be performed on which resources. The following capabilities are available for LiveObjects: + +* @object-subscribe@ - grants clients read access to LiveObjects, allowing them to get the root object and subscribe to updates. +* @object-publish@ - grants clients write access to LiveObjects, allowing them to perform mutation operations on objects. + +To use LiveObjects, an API key must have at least the @object-subscribe@ capability. With only this capability, clients will have read-only access, preventing them from calling mutation methods on LiveObjects. + +For the purposes of this guide, make sure your API key includes both @object-subscribe@ and @object-publish@ "capabilities":/docs/auth/capabilities to allow full read and write access. + +h2(#step-1). Install Ably Pub/Sub SDK + +blang[javascript]. + + LiveObjects is available as part of the Ably Pub/Sub SDK via the dedicated Objects plugin. + +blang[javascript]. + + h3(#npm). NPM + + Install the Ably Pub/Sub SDK as an "NPM module":https://www.npmjs.com/package/ably: + + ```[sh] + npm install ably + ``` + + Import the SDK and the Objects plugin into your project: + + ```[javascript] + import * as Ably from 'ably'; + import Objects from 'ably/objects'; + ``` + +blang[javascript]. + + h3(#cdn). CDN + + Reference the Ably Pub/Sub SDK and the Objects plugin within your HTML file: + + ```[html] + + + + ``` + +h2(#step-2). Instantiate a client + +blang[javascript]. + + Instantiate an Ably Realtime client from the Pub/Sub SDK, providing the Objects plugin: + + ```[javascript] + const realtimeClient = new Ably.Realtime({ key: '{{API_KEY}}', plugins: { Objects } }); + ``` + +blang[javascript]. + + A "@ClientOptions@":/docs/api/realtime-sdk#client-options object may be passed to the Pub/Sub SDK instance to further customize the connection, however at a minimum you must set an API key and provide an @Objects@ plugin so that the client can use LiveObjects functionality. + +h2(#step-3). Create a channel + +LiveObjects is managed and persisted on "channels":/docs/channels. To use LiveObjects, you must first create a channel with the correct "channel mode flags":/docs/channels/options#modes : + +* @OBJECT_SUBSCRIBE@ - required to access objects on a channel. +* @OBJECT_PUBLISH@ - required to create and modify objects on a channel. + + + +blang[javascript]. + + ```[javascript] + const channelOptions = { modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH'] }; + const channel = realtimeClient.channels.get('my_liveobjects_channel', channelOptions); + ``` + +Next, you need to "attach to the channel":/docs/channels/states. Attaching to a channel starts an initial synchronization sequence where the objects on the channel are sent to the client. + +blang[javascript]. + + ```[javascript] + await channel.attach(); + ``` + +h2(#step-4). Get root object + +The "@channel.objects@":/docs/api/realtime-sdk/channels#objects property gives access to the LiveObjects API for a channel. + +Use it to get the root object, which is the entry point for accessing and persisting objects on a channel. The root object is a "@LiveMap@":/docs/liveobjects/map instance that always exists on a channel and acts as the top-level node in your object tree. You can get the root object using the @getRoot()@ function of LiveObjects: + +blang[javascript]. + + ```[javascript] + // The promise resolves once the LiveObjects state is synchronized with the Ably system + const root = await channel.objects.getRoot(); + ``` + +h2(#step-5). Create objects + +You can create new objects using dedicated functions of the LiveObjects API at "@channel.objects@":/docs/api/realtime-sdk/channels#objects. To persist them on a channel and share them between clients, you must assign objects to a parent @LiveMap@ instance connected to the root object. The root object itself is a @LiveMap@ instance, so you can assign objects to the root and start building your object tree from there. + + + +blang[javascript]. + + ```[javascript] + const visitsCounter = await channel.objects.createCounter(); + const reactionsMap = await channel.objects.createMap(); + + await root.set('visits', visitsCounter); + await root.set('reactions', reactionsMap); + ``` + +h2(#step-6). Subscribe to updates + +Subscribe to realtime updates to objects on a channel. You will be notified when an object is updated by other clients or by you. + +blang[javascript]. + + ```[javascript] + visitsCounter.subscribe(() => { + console.log('Visits counter updated:', visitsCounter.value()); + }); + + reactionsMap.subscribe(() => { + console.log('Reactions map updated:', [...reactionsMap.entries()]); + }); + ``` + +h2(#step-7). Update objects + +Update objects using mutation methods. All subscribers (including you) will be notified of the changes when you update an object: + +blang[javascript]. + + ```[javascript] + await visitsCounter.increment(5); + // console: "Visits counter updated: 5" + await visitsCounter.decrement(2); + // console: "Visits counter updated: 2" + + await reactionsMap.set('like', 10); + // console: "Reactions map updated: [['like',10]]" + await reactionsMap.set('love', 10); + // console: "Reactions map updated: [['like',10],['love',5]]" + await reactionsMap.remove('like'); + // console: "Reactions map updated: [['love',5]]" + ``` + + + +h2(#step-8). Next steps + +This quickstart introduced the basic concepts of LiveObjects and demonstrated how it works. The next steps are to: + +* Read more about "LiveCounter":/docs/liveobjects/counter and "LiveMap":/docs/liveobjects/map. +* Learn about "Batching Operations":/docs/liveobjects/batch. +* Learn about "Objects Lifecycle Events":/docs/liveobjects/lifecycle. +* Explore "LiveObjects examples":https://examples.ably.dev. +* Add "Typings":/docs/liveobjects/typing for your LiveObjects. From 5a41d6e25ed5a62c091d3faa0e71b16f0806df0f Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 6 Mar 2025 07:31:18 +0000 Subject: [PATCH 13/38] Add LiveObjects LiveCounter docs --- content/liveobjects/counter.textile | 186 ++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 content/liveobjects/counter.textile diff --git a/content/liveobjects/counter.textile b/content/liveobjects/counter.textile new file mode 100644 index 0000000000..15ace81aaa --- /dev/null +++ b/content/liveobjects/counter.textile @@ -0,0 +1,186 @@ +--- +title: LiveCounter +meta_description: "Create, update and receive updates for a numerical counter that synchronizes state across clients in realtime." +product: liveobjects +languages: + - javascript +--- + +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. + +h2(#create). Create LiveCounter + +A @LiveCounter@ instance can be created using the @channel.objects.createCounter()@ method. It must be stored inside a @LiveMap@ object that is reachable from the "root object":/docs/liveobjects/quickstart#step-4. + +blang[javascript]. + + @channel.objects.createCounter()@ is asynchronous, as the client sends the create operation to the Ably system and waits for an acknowledgment of the successful counter creation. + + + +blang[javascript]. + + ```[javascript] + const counter = await channel.objects.createCounter(); + await root.set('counter', counter); + ``` + +Optionally, you can specify an initial value when creating the counter: + +blang[javascript]. + + ```[javascript] + const counter = await channel.objects.createCounter(100); // Counter starts at 100 + ``` + +h2(#value). Get counter value + +Get the current value of a counter using the @LiveCounter.value()@ method: + +blang[javascript]. + + ```[javascript] + console.log('Counter value:', counter.value()); + ``` + +h2(#subscribe-data). Subscribe to data updates + +You can subscribe to data updates on a counter to receive realtime changes made by you or other clients. + + + +Subscribe to data updates on a counter using the @LiveCounter.subscribe()@ method: + +blang[javascript]. + + ```[javascript] + counter.subscribe((update) => { + console.log('Counter updated:', counter.value()); + console.log('Update details:', update); + }); + ``` + +The update object provides details about the change, such as the amount by which the counter value was changed. + +Example structure of an update object when the counter was incremented by 5: + +```[json] +{ + { + "amount": 5 + } +} +``` + +Or decremented by 10: + +```[json] +{ + { + "amount": -10 + } +} +``` + +h3(#unsubscribe-data). Unsubscribe from data updates + +Use the @unsubscribe()@ function returned in the @subscribe()@ response to remove a counter update listener: + +blang[javascript]. + + ```[javascript] + // Initial subscription + const { unsubscribe } = counter.subscribe(() => console.log(counter.value())); + // To remove the listener + unsubscribe(); + ``` + +Use the @LiveCounter.unsubscribe()@ method to deregister a provided listener: + +blang[javascript]. + + ```[javascript] + // Initial subscription + const listener = () => console.log(counter.value()); + counter.subscribe(listener); + // To remove the listener + counter.unsubscribe(listener); + ``` + +Use the @LiveCounter.unsubscribeAll()@ method to deregister all counter update listeners: + +blang[javascript]. + + ```[javascript] + counter.unsubscribeAll(); + ``` + +h2(#update). Update LiveCounter + +Update the counter value by calling @LiveCounter.increment()@ or @LiveCounter.decrement()@. These operations are synchronized across all clients and trigger data subscription callbacks for the counter, including on the client making the request. + +blang[javascript]. + + These operations are asynchronous, as the client sends the corresponding update operation to the Ably system and waits for acknowledgment of the successful counter update. + +blang[javascript]. + + ```[javascript] + await counter.increment(5); // Increase value by 5 + await counter.decrement(2); // Decrease value by 2 + ``` + +h2(#subscribe-lifecycle). Subscribe to lifecycle events + +Subscribe to lifecycle events on a counter using the @LiveCounter.on()@ method: + +blang[javascript]. + + ```[javascript] + counter.on('deleted', () => { + console.log('Counter has been deleted'); + }); + ``` + +Read more about "objects lifecycle events":/docs/liveobjects/lifecycle#objects. + +h3(#unsubscribe-lifecycle). Unsubscribe from lifecycle events + +Use the @off()@ function returned in the @on()@ response to remove a lifecycle event listener: + +blang[javascript]. + + ```[javascript] + // Initial subscription + const { off } = counter.on(('deleted') => console.log('Counter deleted')); + // To remove the listener + off(); + ``` + +Use the @LiveCounter.off()@ method to deregister a provided lifecycle event listener: + +blang[javascript]. + + ```[javascript] + // Initial subscription + const listener = () => console.log('Counter deleted'); + counter.on('deleted', listener); + // To remove the listener + counter.off('deleted', listener); + ``` + +Use the @LiveCounter.offAll()@ method to deregister all lifecycle event listeners: + +blang[javascript]. + + ```[javascript] + counter.offAll(); + ``` From 35cd419175245b15454aa0b3865c9301f1a16194 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 6 Mar 2025 09:08:14 +0000 Subject: [PATCH 14/38] Add LiveObjects LiveMap docs --- content/liveobjects/map.textile | 246 ++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 content/liveobjects/map.textile diff --git a/content/liveobjects/map.textile b/content/liveobjects/map.textile new file mode 100644 index 0000000000..4f5e9b2745 --- /dev/null +++ b/content/liveobjects/map.textile @@ -0,0 +1,246 @@ +--- +title: LiveMap +meta_description: "Create, update and receive updates for a key/value data structure that synchronizes state across clients in realtime." +product: liveobjects +languages: + - javascript +--- + +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":#composability. + +Conflicts in a LiveMap are automatically resolved with last-write-wins (LWW) semantics. The latest received operation on a key will be applied to the LiveMap and broadcast to all clients. + +h2(#create). Create LiveMap + +A @LiveMap@ instance can be created using the @channel.objects.createMap()@ method. It must be stored inside another @LiveMap@ object that is reachable from the "root object":/docs/liveobjects/quickstart#step-4. + +blang[javascript]. + + @channel.objects.createMap()@ is asynchronous, as the client sends the create operation to the Ably system and waits for an acknowledgment of the successful map creation. + + + +blang[javascript]. + + ```[javascript] + const map = await channel.objects.createMap(); + await root.set('myMap', map); + ``` + +Optionally, you can specify an initial key/value structure when creating the map: + +blang[javascript]. + + ```[javascript] + // Pass a regular JavaScript object reflecting the initial state + const map = await channel.objects.createMap({ foo: 'bar', baz: 42 }); + // You can also pass other objects as values for keys + await channel.objects.createMap({ nestedMap: map }); + ``` + +h2(#value). Get value for a key + +Get the current value for a key in a map using the @LiveMap.get()@ method: + +blang[javascript]. + + ```[javascript] + console.log('Value for my-key:', map.get('my-key')); + ``` + +h2(#subscribe-data). Subscribe to data updates + +You can subscribe to data updates on a map to receive realtime changes made by you or other clients. + + + +Subscribe to data updates on a map using the @LiveMap.subscribe()@ method: + +blang[javascript]. + + ```[javascript] + map.subscribe((update) => { + console.log('Map updated:', [...map.entries()]); + console.log('Update details:', update); + }); + ``` + +The update object provides details about the change, listing the keys that were changed and indicating whether they were updated (value changed) or removed from the map. + +Example structure of an update object when the key @foo@ is updated and the key @bar@ is removed: + +```[json] +{ + { + "foo": "updated", + "bar": "removed" + } +} +``` + +h3(#unsubscribe-data). Unsubscribe from data updates + +Use the @unsubscribe()@ function returned in the @subscribe()@ response to remove a map update listener: + +blang[javascript]. + + ```[javascript] + // Initial subscription + const { unsubscribe } = map.subscribe(() => console.log('Map updated')); + // To remove the listener + unsubscribe(); + ``` + +Use the @LiveMap.unsubscribe()@ method to deregister a provided listener: + +blang[javascript]. + + ```[javascript] + // Initial subscription + const listener = () => console.log('Map updated'); + map.subscribe(listener); + // To remove the listener + map.unsubscribe(listener); + ``` + +Use the @LiveMap.unsubscribeAll()@ method to deregister all map update listeners: + +blang[javascript]. + + ```[javascript] + map.unsubscribeAll(); + ``` + +h2(#set). Set keys in a LiveMap + +Set a value for a key in a map by calling @LiveMap.set()@. This operation is synchronized across all clients and triggers data subscription callbacks for the map, including on the client making the request. + +Keys in a map can contain numbers, strings, booleans and buffers, as well as other @LiveMap@ and @LiveCounter@ objects. + +blang[javascript]. + + This operation is asynchronous, as the client sends the corresponding update operation to the Ably system and waits for acknowledgment of the successful map key update. + +blang[javascript]. + + ```[javascript] + await map.set('foo', 'bar'); + await map.set('baz', 42); + + // Can also set a reference to another object + const counter = await channel.objects.createCounter(); + await map.set('counter', counter); + ``` + +h2(#remove). Remove a key from a LiveMap + +Remove a key from a map by calling @LiveMap.remove()@. This operation is synchronized across all clients and triggers data subscription callbacks for the map, including on the client making the request. + +blang[javascript]. + + This operation is asynchronous, as the client sends the corresponding remove operation to the Ably system and waits for acknowledgment of the successful map key removal. + +blang[javascript]. + + ```[javascript] + await map.remove('foo'); + ``` + +h2(#iterate). Iterate over key/value pairs + +blang[javascript]. + + Iterate over key/value pairs, keys or values using the @LiveMap.entries()@, @LiveMap.keys()@ and @LiveMap.values()@ methods respectively. + + These methods return a "map iterator":https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator object for convenient traversal. Note that contrary to JavaScript's "Map":https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map counterpart, these methods do not guarantee that entries are returned in insertion order. + +blang[javascript]. + + ```[javascript] + for (const [key, value] of map.entries()) { + console.log(`Key: ${key}, Value: ${value}`); + } + + for (const key of map.keys()) { + console.log(`Key: ${key}`); + } + + for (const value of map.values()) { + console.log(`Value: ${value}`); + } + ``` + +h2(#subscribe-lifecycle). Subscribe to lifecycle events + +Subscribe to lifecycle events on a map using the @LiveMap.on()@ method: + +blang[javascript]. + + ```[javascript] + map.on('deleted', () => { + console.log('Map has been deleted'); + }); + ``` + +Read more about "objects lifecycle events":/docs/liveobjects/lifecycle#objects. + +h3(#unsubscribe-lifecycle). Unsubscribe from lifecycle events + +Use the @off()@ function returned in the @on()@ response to remove a lifecycle event listener: + +blang[javascript]. + + ```[javascript] + // Initial subscription + const { off } = map.on(('deleted') => console.log('Map deleted')); + // To remove the listener + off(); + ``` + +Use the @LiveMap.off()@ method to deregister a provided lifecycle event listener: + +blang[javascript]. + + ```[javascript] + // Initial subscription + const listener = () => console.log('Map deleted'); + map.on('deleted', listener); + // To remove the listener + map.off('deleted', listener); + ``` + +Use the @LiveMap.offAll()@ method to deregister all lifecycle event listeners: + +blang[javascript]. + + ```[javascript] + map.offAll(); + ``` + +h2(#composability). Composability + +A @LiveMap@ can store other @LiveMap@ or @LiveCounter@ objects as values for its keys, enabling you to build complex, hierarchical object structure. This enables you to represent complex data models in your applications while ensuring realtime synchronization across clients. + +blang[javascript]. + + ```[javascript] + // Create a hierarchy of objects using LiveMap + const counter = await channel.objects.createCounter(); + const map = await channel.objects.createMap({ nestedCounter: counter }); + const outerMap = await channel.objects.createMap({ nestedMap: map }); + await root.set('outerMap', outerMap); + + // resulting structure: + // root (LiveMap) + // └── outerMap (LiveMap) + // └── nestedMap (LiveMap) + // └── nestedCounter (LiveCounter) + ``` From 1cae8e9c8f0e07205b9994883c0fd4a08b391594 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 6 Mar 2025 09:51:39 +0000 Subject: [PATCH 15/38] Add LiveObjects Batch Operations docs --- content/liveobjects/batch.textile | 148 ++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 content/liveobjects/batch.textile diff --git a/content/liveobjects/batch.textile b/content/liveobjects/batch.textile new file mode 100644 index 0000000000..649facaf67 --- /dev/null +++ b/content/liveobjects/batch.textile @@ -0,0 +1,148 @@ +--- +title: Batch operations +meta_description: "Group multiple objects operations into a single channel message to apply grouped operations atomically and improve performance." +product: liveobjects +languages: + - javascript +--- + +The Batching API in LiveObjects enables 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 operations 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. Without batching, if one operation succeeds while another fails, your application state could become inconsistent. + +Note that this differs from "Message batching":/docs/messages/batch, the native Pub/Sub messages feature. The LiveObjects Batching API is a separate API specifically designed to enable you to group object operations into a single channel message, ensuring that the Ably system guarantees the atomicity of the applied changes. + +h2(#create). Create batch context + +blang[javascript]. + + To batch object operations together, use the @channel.objects.batch()@ method. This method accepts a callback function, which is provided with a batch context object. The batch context object provides a synchronous API to work with objects on a channel that stores operations inside the batch instead of applying them immediately. + + Using the batch context ensures that operations are grouped and sent in a single channel message after the batch callback function has run. This guarantees that all changes are applied atomically by both the server and all clients. + + + +blang[javascript]. + + ```[javascript] + await channel.objects.batch((ctx) => { + const root = ctx.getRoot(); + + root.set('foo', 'bar'); + root.set('baz', 42); + + const counter = root.get('counter'); + counter.increment(5); + + // Batched operations are sent to the Ably system when the batch callback has run. + }); + ``` + +If an error occurs within the batch, all operations are discarded, preventing partial updates and ensuring atomicity. + +h3(#context). Batch context object + +blang[javascript]. + + The batch context provides a synchronous API for objects operations inside the batch callback. It mirrors the asynchronous API found on @channel.objects@, including "LiveCounter":/docs/liveobjects/counter and "LiveMap":/docs/liveobjects/map. + + To access the batch API, call @BatchContext.getRoot()@, which synchronously returns a wrapper around the "root":/docs/liveobjects/quickstart#step-4 object instance. This wrapper enables you to access and modify objects within a batch. + + + +blang[javascript]. + + ```[javascript] + await channel.objects.batch((ctx) => { + // Note: .getRoot() call on a batch context is synchronous. + // The returned root object is a special wrapper around a regular LiveMap instance, + // providing a synchronous mutation API. + const root = ctx.getRoot(); + + // Mutation operations like LiveMap.set and LiveCounter.increment + // are synchronous inside the batch and queue operations instead of applying them immediately. + root.set('foo', 'bar'); + root.remove('baz'); + + // Access other objects through the root object from the BatchContext.getRoot() method. + const counter = root.get('counter'); + counter.increment(5); + }); + ``` + +You cannot create new objects using the batch context. If you need to create new objects and add them to the channel as part of an atomic batch operation to guarantee atomicity, you must first create them using the regular @channel.objects@ API. Once the objects have been created, you can then assign them to the object tree inside a batch function. + +blang[javascript]. + + ```[javascript] + // First, create new objects outside the batch context + const counter = await channel.objects.createCounter(); + const map = await channel.objects.createMap(); + + // Then, use a batch to assign them atomically to the channel objects + await channel.objects.batch((ctx) => { + const root = ctx.getRoot(); + root.set('counter', counter); + root.set('map', map); + }); + ``` + +h3(#use-cases). When to batch operations + +Usually, you don't need to use batching for objects operations. It is only useful in situations where a group of operations must be applied together to maintain consistency in application state, or when there are multiple mutation operations that you might want to apply at the same time to improve the UI experience. + +For example, in a task dashboard application, you might want to remove all tasks on a board in a single operation to prevent excessive UI updates that the user would otherwise experience. + +blang[javascript]. + + ```[javascript] + await channel.objects.batch((ctx) => { + const root = ctx.getRoot(); + const tasks = root.get('tasks'); + + for (const key of reactions.keys()) { + reactions.remove(key); + } + }); + ``` + +h3(#cancel). Cancel batch operation + +To explicitly cancel a batch before it is applied, throw an error inside the batch function. This prevents any queued operations from being applied. + +blang[javascript]. + + ```[javascript] + await channel.objects.batch((ctx) => { + const root = ctx.getRoot(); + root.set('foo', 'bar'); + + // Throwing an error prevents any queued operations from being applied. + throw new Error('Cancel batch'); + }); + ``` + +blang[javascript]. + + h3(#closed). Batch API cannot be used outside the callback function + + The Batch API provided by the batch context object cannot be used outside the callback function. Attempting to do so results in an error. This applies both to @BatchContext.getRoot()@ and any object instances retrieved from it. + +blang[javascript]. + + ```[javascript] + let root; + await channel.objects.batch((ctx) => { + root = ctx.getRoot(); + }); + + // Calling any Batch API methods outside the batch callback + // will throw an Error: Batch is closed. + root.set('foo', 'bar'); + ``` From b4b4a6f3e17102be86e5f2807583992d9e84ce36 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 7 Mar 2025 07:02:33 +0000 Subject: [PATCH 16/38] Add LiveObjects Lifecycle events docs --- content/liveobjects/lifecycle.textile | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 content/liveobjects/lifecycle.textile diff --git a/content/liveobjects/lifecycle.textile b/content/liveobjects/lifecycle.textile new file mode 100644 index 0000000000..cf97012bba --- /dev/null +++ b/content/liveobjects/lifecycle.textile @@ -0,0 +1,66 @@ +--- +title: Lifecycle events +meta_description: "Understand lifecycle events for Objects, LiveMap and LiveCounter to track synchronization events and object deletions." +product: liveobjects +languages: + - javascript +--- + +h2(#synchronization). Objects synchronization events + +The "@channel.objects@":/docs/api/realtime-sdk/channels#objects instance emits synchronization events that indicate when the local state on the client is being synchronized with the Ably service. These events can be useful for displaying loading indicators, preventing user interactions during synchronization, or triggering application logic when data is first loaded. + +* @syncing@ - Emitted when the local copy of objects on a channel begins synchronizing with the Ably service. +* @synced@ - Emitted when the local copy of objects on a channel has been synchronized with the Ably service. + +blang[javascript]. + + ```[javascript] + channel.objects.on('syncing', () => { + console.log('Objects are syncing...'); + // Show a loading indicator and disable edits in the application + }); + + channel.objects.on('synced', () => { + console.log('Objects have been synced.'); + // Hide loading indicator + }); + ``` + + + +h2(#objects-lifecycle). LiveMap/LiveCounter lifecycle events + +Lifecycle events enable you to monitor changes in an object's lifecycle. + +Currently, only the @deleted@ event can be emitted. Understanding the conditions under which this event is emitted and handling it properly ensures that your application maintains expected behavior. + +h3(#objects-deleted). deleted event + +Objects that were created on a channel can become orphaned when they were never assigned to the object tree, or because their reference was removed using "@LiveMap.remove()@":/docs/liveobjects/map#remove and never reassigned. Orphaned objects will be garbage collected by Ably, typically after 24 hours. When this happens, a @deleted@ event is broadcast for the affected object. Once deleted, an object can no longer be interacted with, and any operations performed on it will result in an error. + +While the LiveObjects feature internally manages object deletions and removes them from its internal state, your application may still hold references to these deleted objects in separate data structures. The @deleted@ event provides a way to react accordingly by removing references to deleted objects and preventing potential errors. + +In most cases, subscribing to @deleted@ events is unnecessary. Your application should have already reacted to object removal when a corresponding "@LiveMap.remove()@":/docs/liveobjects/map#remove operation was received. However, if your application separately stores references to object instances and does not properly clear them when objects are orphaned, any later interactions with those objects after they are deleted will result in an error. In such cases, subscribing to @deleted@ events helps ensure that those references are cleaned up and runtime errors are avoided. + + + +blang[javascript]. + + ```[javascript] + const { off } = counter.on('deleted', () => { + console.log('LiveCounter has been deleted.'); + // Remove references to this object from your application + // as it can no longer be interacted with + }); + ``` + +Read more about subscribing to object lifecycle events for "LiveCounter":/docs/liveobjects/counter#subscribe-lifecycle and "LiveMap":/docs/liveobjects/map#subscribe-lifecycle. From 5a0dd9d8ab97fb8d572d6cfd223cd7badb5e9f6b Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 7 Mar 2025 08:09:38 +0000 Subject: [PATCH 17/38] Add LiveObjects Typings docs --- content/liveobjects/typing.textile | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 content/liveobjects/typing.textile diff --git a/content/liveobjects/typing.textile b/content/liveobjects/typing.textile new file mode 100644 index 0000000000..5a00243cde --- /dev/null +++ b/content/liveobjects/typing.textile @@ -0,0 +1,87 @@ +--- +title: Typing +meta_description: "Type objects on a channel for type safety and code autocompletion." +product: liveobjects +languages: + - javascript +--- + +blang[javascript]. + + If you are using TypeScript in your project, you can leverage LiveObjects' built-in TypeScript support to ensure type safety and enable autocompletion when working with objects on a channel. + + h2(#global). Global ObjectsTypes interface + + You can type objects on all your channels by defining a global @ObjectsTypes@ interface. If you only want to type the root object for a specific channel, see the "Typing channel.objects.getRoot()":#getroot section below. + + Define the @ObjectsTypes@ interface in a type declaration file. You can create a file named @ably.config.d.ts@ in the root of your application: + +blang[javascript]. + + ```[javascript] + // file: ably.config.d.ts + import { LiveCounter, LiveMap } from 'ably'; + + // Define dedicated types and export them for reuse in your application + export type MyCustomRoot = { + reactions: LiveMap<{ + hearts: LiveCounter; + likes: LiveCounter; + }>; + }; + + declare global { + export interface ObjectsTypes { + root: MyCustomRoot; + } + } + ``` + +blang[javascript]. + + This enables TypeScript to infer the correct types when accessing and mutating LiveObjects: + +blang[javascript]. + + ```[javascript] + // LiveMap<{ reactions: LiveMap<{ hearts: LiveCounter; likes: LiveCounter }> }> + const root = await channel.objects.getRoot(); + + // LiveMap<{ hearts: LiveCounter; likes: LiveCounter }> + const reactions = root.get('reactions'); + + // LiveCounter + const likes = reactions.get('likes'); + + reactions.set('hearts', 1); // Error: Argument of type 'number' is not assignable to parameter of type 'LiveCounter'.ts(2345) + ``` + +blang[javascript]. + + h2(#getroot). Typing channel.objects.getRoot() + + You can pass a type parameter directly to the @channel.objects.getRoot()@ method call to type the root object for a channel explicitly: + +blang[javascript]. + + ```[javascript] + // Define types for different root objects + type ReactionsRoot = { + hearts: LiveCounter; + likes: LiveCounter; + }; + + type PollsRoot = { + currentPoll: LiveMap; + }; + + // LiveMap<{ hearts: LiveCounter; likes: LiveCounter }> + const reactionsRoot = await reactionsChannel.objects.getRoot(); + + // LiveMap<{ currentPoll: LiveMap }> + const pollsRoot = await pollsChannel.objects.getRoot(); + ``` + +blang[javascript]. + + Typing @channel.objects.getRoot()@ is particularly useful when your application uses multiple channels, each with a different object structure. From 6532e7ff31e55eace57a9bcd05cd3703c34ba600 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 11 Apr 2025 10:09:54 +0100 Subject: [PATCH 18/38] Use sentence case in navigation --- src/data/nav/liveobjects.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index fc0627eaad..9957270722 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -38,11 +38,11 @@ export default { link: '/docs/liveobjects/map', }, { - name: 'Batch Operations', + name: 'Batch operations', link: '/docs/liveobjects/batch', }, { - name: 'Lifecycle Events', + name: 'Lifecycle events', link: '/docs/liveobjects/lifecycle', }, { From 1220eafe155945433e235c7e215b733949ab603d Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 11 Apr 2025 09:22:37 +0100 Subject: [PATCH 19/38] Add Advanced section for some of the LiveObjects navigation items --- src/data/nav/liveobjects.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index 9957270722..11bdecd7fc 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -37,6 +37,11 @@ export default { name: 'LiveMap', link: '/docs/liveobjects/map', }, + ], + }, + { + name: 'Advanced', + pages: [ { name: 'Batch operations', link: '/docs/liveobjects/batch', From 770b56e8b3732fc8a4f243415df249a7675625d4 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 11 Apr 2025 11:10:20 +0100 Subject: [PATCH 20/38] Add LiveObjects section to platform/index --- content/platform/index.textile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/content/platform/index.textile b/content/platform/index.textile index e57a103245..d565d748ae 100644 --- a/content/platform/index.textile +++ b/content/platform/index.textile @@ -67,6 +67,14 @@ Spaces is an abstraction built over Ably Pub/Sub. It utilizes Ably's platform to Spaces is effective when building features such as interactive whiteboards, avatar stacks, and displaying and locking elements on a page, such as a cell in a spreadsheet, or a slide in a slideshow presentation. +h3(#liveobjects). Ably LiveObjects + +Use Ably "LiveObjects":/docs/liveobjects to synchronize application state across users and devices in realtime. LiveObjects provides purpose-built APIs and data structures for managing shared state, and it automatically handles concurrency, conflict resolution, synchronization and persistence. + +LiveObjects is managed and persisted on Ably Pub/Sub channels. It utilizes Ably's platform to benefit from all of the same performance guarantees and scaling potential. + +LiveObjects is effective for use cases such as realtime voting and polling systems, collaborative applications, live leaderboards, multiplayer game state synchronization, and any other scenario where application data is shared, can be updated concurrently by many users, and needs to be synchronized in realtime. + h3(#livesync). Ably LiveSync Use Ably "LiveSync":/docs/livesync to synchronize changes between your database and frontend clients. It provides support for PostgreSQL and MongoDB and uses the Ably platform to synchronize your application's data. From 25822ce7c4c8fc8fe7ba5b5435b1e2bf0a8a7a5d Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Fri, 11 Apr 2025 11:16:45 +0100 Subject: [PATCH 21/38] Add comment explaining reusing the assetTracking badge color for liveObjects in ExamplesGrid --- src/components/Examples/ExamplesGrid.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Examples/ExamplesGrid.tsx b/src/components/Examples/ExamplesGrid.tsx index 6b429518d7..2418c989c4 100644 --- a/src/components/Examples/ExamplesGrid.tsx +++ b/src/components/Examples/ExamplesGrid.tsx @@ -32,6 +32,7 @@ const ExamplesGrid = ({ case 'assetTracking': return 'text-green-600'; case 'liveObjects': + // Reusing Asset Tracking color as no examples exist for it yet. return 'text-green-600'; default: return 'text-orange-700'; From edcfcf0a91c5a47ef76b712937bfb2f1f4c938f3 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 17 Apr 2025 17:18:23 +0100 Subject: [PATCH 22/38] Add LiveObjects image for docs homepage --- src/images/homepage/liveobjects.png | Bin 0 -> 133147 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/images/homepage/liveobjects.png diff --git a/src/images/homepage/liveobjects.png b/src/images/homepage/liveobjects.png new file mode 100644 index 0000000000000000000000000000000000000000..fdc86e503db8fa3983b251a9130737a6265e5453 GIT binary patch literal 133147 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ⅆa}BZb zwRiQ$U7mHD(Kn^y6aS}P9r`oR)^=dQa6_yTdEbQ`po+eK+j(DhgDTlI-p^pnc5%d#?GN*J%yop zHB=v|Ft0+W2Z|3_SU16kDK?t!~(bRKX#x?+?tibJv)2cW+QfGi}G)`{i%@3eVRQ3BL911sH)pQj}5W zoEQg)7?*we7|XELBzloO$ZnCkHKJ6NNgv7{3OXz#2ihr4VhL6j*g9kQF z2FaS%vivq_IGO^o0iS?iZI@u8?~R7S%j_w3yDd6g%5q{A?Kd6se<@HXWdxT1APuzr z2;iL5Nv{`c+fCLN{4NQeOFMp2b!htju8__1;w0_pFqgxWf@O9Ofz@8*#n`m-l!C*# zzE#EVBy-?#t&2@u+vm!6?00lv#Utl{Z29r21)p)27VOj_JC|dcfRw5IeXS@$;eO!y zjptp#7_GyQ!Y9e0_NzO-W`G=U@O~CUKZp+4o%pw&5`$x|ze0NV)m2|iFD6HwsV&X%)1z*U`p6i_qL+hQ&l2-h;05a+0*e9_R1(74 zXJd@I(jLu>>cmtV_yRZk3%{3-R@`aoO8hlU?dC-zfV#Swv`xlyF4|P!cRf@XB!nVB?3NMNbWYmGdByhxewVq+H`M#r#RM18yd(F&>8)L6 z$CjQI7bt8OpuRe`k z)){>3B8OT_x1J@JobOLh4Y=ckDnh|EV{zU;?3oUgeHsk>-CL|ncEFj&{8k2&V|Pa8 zfgf3Kj`m~39Ntp`3|no0t;5jVG|OTU_h(bB<(T5xDkY-U2~56PAo+LMdw#sVt)2Xg z>H+Pv*gY4&(LTGI9M=|eae|#ZCB$l|S2czdF&b|6_3s5)m`Xlz53d!((ix^L zG^akHG@rv$a(A1z59|rs2LD-ZUv{?NOy;lk^++ z&e^x0_4*c_KGhWu3b4+_JE5b^BD5cj&YUUL6nmtxpB>w{R)hx%)sk#?))O< zapwZ>MrB4-TYS&_y+5VgF=BFa^%k#Af2hyR;PUvaYoP6ioPfo&ldHhtwTdc?=Qe&R znE5=>G&K#h@vW6OET;0t*h|D@c7|Z}&d#jL-}TJ3s-b+^LXbi;^J+NZOW-u1c48V!wsJ77wrJCH^;^nc3c4%}zh ztPBy4H-~uDUh}b%io}9>H79(<*=kZJmY;*!90NPqMDFs_=_rZiZ@=xemkFG3NWD75 z|Ll0gS8WB$n{!PkaS2Zl{%U%|v*e3X*#0^qc~&RyPgV_!odNG;Hfmuhx*HNm^(%mz zQ|)%`#N;&nmJEuZ^%hG*(g=63Vzf;(lC!jA%NHn0zFvHd z?W!q;YczWre|oO9a9~K+SWXCYFiPY-_28e-ZO*hddT-iY zr>=9lzC6#5EksaVbDh|hid^=Z7*)zf)^5i7UzO#&OfW7%CCY@@Fhl#*A2I@M?rXiC za*2F(+6TWd3Omk6SWi6*+xN(q zGBqkR^B$!(03n0M{Ig-UjaCkFC%NzD8Ig1#5d#H7T(Nfl_b~v}*udySzkz;<0C^VP zG!gd@YF_r+`;YuAWCYB1k^$i7bQ)IQ#g^R*DrJqGKX7%&=jP~r?!Z{GcMeTbti+U( zp?Y7YK00>JA6PCo4NY3W>1-(7qncw2@-OxpO^gqEU(x<@y-!NyqFD3jC?ox=?1*oG zxBGe#c=MGZUjDdP$0(!)Bq=co!3?SqN^?{BGe)-jgsEmarQg?2k6OHVcMF4#e{ngu z!+1ri9{zAeK!g@_P|BB6HYnF1roN7B8mGC#;&p0xMd8M;zfbu>?o}&qDk8xGvIBmc zuRvPfbcQp@IFr%k{K%Rb&@kIg$En((cDARsFMYxflG>{7^9BT~!O^E#ZSc@5GhrU( z5B&=@-T^C9Gu287N+vx9=Le|g&2{Vd)i;^Bk59lPBCt{-e4cRVa;9d|ulVE1+>6fM zwHAkm=wWj{)10E9f_pLrt%};D@=?e=hTvt>#~;`f@%tOMWdfdgs;nls2*u!)kTAF77yz zfBr?V;?+AiHpUYGZqn7bz9~-aaE?DEK>_Qewrl0o#8kxw>fC`qdDFLRS1Wnr%(nVf zQ!fAvy;lsch~e`6fe%SWR}ogN(X>z9UC~yBX!J^6+g*3tPCJM4w$9v&9*mTG4Abm; zycvnZVlz~EvFxX>v;`1NwjWAJhe+xHJR8@;_47vzPx7u+0A0f4e>ZcoZpYHH{Y~oZ z`}$n)g~e*uL+)5D=C8|}XIC9B+*0skG2${dmIg6oFIHmHQm~06cTT!*Av@QqaU;;j zf&%^KZSqGQ%Z+;xB{Qqz%KL>IA&RzRBby$*0j4d48hb+5mxsRX@6GOoJQn> zx0bkD)uxsT<;@)ro)~FOa@f{4qmc7IG3p9r$G6UUu{vN>p@ zRiD|u=P2+J=^utiE}A5U8lSh+uc|bP+vEGg>E4X1nek0a{c7EdSQE1R7q3vy7cG~8 z2~g_G$nJaplm z^jyDaM+B_pi!4;>NIf2gZQP(uALVfClh|O%*(vE=ROaJq>IrKK)5UJC`*Agl15GT_SA+xKG*Iv@ zVWS~~ryX+u5v^s!$4=H-{}NwID-x?yk<@t9+9Q~gW)TI*kdN`1g-f8vQpoI91 zH(FjhtO@5(s?EVypjb+sn#;|J@*Vx-^vup@*u!udS~Dit+G!8D|d9pAKtl3XesLGnxfYb9nd9^J4Nx*A_kB~j$3iM@N|S#Kg{^B6yJKZ)qN z@+YewoNpSRGYZ+~Rq@!n=j|yUsv}o&pJ|H@)z7!Sx_^K3J}RhR)1mM@ z&*LsX&jY@j0hopBma$S!i3t8M${5Jmy3^viS9?r}41i&IZOmYjrY#PtI?D%co$&^B zQ6;Z403JPRNA!Ar2!bI zQ|ro{F^=)L*ytA_wEE&tRB28#+ZMT4g-v#rjVVej>ftqhs*3H>Tzw~we;Wu9uv>%Oz&IHYrV3Tdp*|ECe{pjmvI#Eo zrH2%(sTiwQn7DE@rLEUyI&U|*@UtKNd`t;FGk5*5w{4nE`;yI6Njmd#4FA>xmfCDx zo2l~j&0(Ld?FG2?4+Upae>J6!1>BOg4wJ2KJ^E8MtG^ z7Z6M>rZYY-hPI&boc-j#Ejd(OI66BI)cmRaeTSm6d@O&kMf7PI46ta6h0Vc(VlcjG zy!yzl2B~Wembqc7EM!dr06g!eU>31OCCi!O-<37iTmo=Sth<_kqXZozJ)$sF$-I91 zQa&)&=BAXL4qazHwHheNADUNIXXrMC_#Wt+`1E0SWl`LPZ>Ve$Zdr~%UexZ{* zt!CnrF;kGw66{5WbOo1q(fp*Dim!AX|M4NZIsz~!X%p@+u!u|;$TQS1;3l~V$G1kI zEW>}n=*M^+YKP!<;`*qCZQAfY0xA30H3}Y-g8Et;Bqi24R7A&Um`JRvfvAP_-}CmG zWRr2KpwfUg@L{RK6iyireAa$nEgkCy5ap#qC+pfPD)eU+9qg$B96Ae~ z#L&0VW(EX8o>!8krM4p5m9Ve{xVLl;;2YVXrR@1Z`Y?-?tl6Ov8lA%#vFb*N(aUG! z1lKc;9rdb8!S<%O+0680lJ8gC`Dy<06FJe4w!jzUh+0=RkG;~s5{VP_9=yMgA+lm%;OhNvm_}XHEX06XBjo zd9?S(wGd&{7))@?@}ME=Ko!=l+_%E3osR=!JHwM^%;WZ10oS4QBjXM&W3cDnC(@Qo zQ?vsk+{>G6YLz-tgWqezzkpzIzhD?3;lvAn$(AyKK*a8?A#=i5v#jxsa~^`mOj!LQ^8hY*O2?0ij*&m7k`7q za3xym*hNEh;xEVUe+J}G5?Cp7&%6|0w){QFb`?juajD!~UN+aC9!o8uEA*Ob2$496 zMpPt8`MR}RD@FD)ME`K;*>XA`(5H_)`tJ<-r@pA^?lsyzhkc)hdU zGsI{)5fKr|rn!_8y_l(>@O0y7+}xBHHCP?iTOHzK+l#w@t+l^5Nb=G633e&|?Q-Q? zn~d-@7OMC{qS-ba9@yU^-MNWQ^6uuEgv`moKuXwd&rEl;X{9uV2elM3?M+U3?{$WGZzPlW-^9zIcwY$U{`6##+4#wzi_BP`F?xUiIyI103$$D7I1 z`e=QxCxMk?*1#3z6)ma3(B%0s6y?eHa^09tNNgWE92E#cOi-Sb3NMvHMLrdo-1&U6 zrXLzQ*_ub~tYm|EN4t5i>RDb{$&3U79e#5lNg5fG@Ufds4Ale+BB^DE9eS za(}>7w{WK(*l=*@sPChklyYd6?;xHyJY4PmR%+R4~kfEc|5OL1;o}Omgh#&Y_`(+fD?tB~OzFcuX#8zcxfsBfN}YLk@LB;M)Ez4C z<1&Y>^f%de;Fo2;^>lvrmC#>*Y8e%+ZzR~cR(Y#;>tP4Q1F^p%H)2QGO+}SL8LXlq zJFdg~J08IkZLuK$t}Rbuvs?majA&fj<&7?8vN&Hy)^%A#_^Zv8~zu^XNtH_BYpRljW2O9d_#L-;j9!+HkjQVCqPc zSw0M@@N0)x9gHDtSHewyuYTW;;5R<1_H1kS-ZF&Ay)Lq9jnu7kQ+P9qo>%4 z?9AvIV>qlvdT0Ki(I95PBF8Lu%+Zs2;QF{Q;+DoR|TNI%GGjFqxyE+ce2 zOfSw4D1TtO2SkGy~sqb#{Hw|oEE;^OyUKQ4H0Z!D2ScaG}T2+xX4 zop|U;gprxMgkm0cw~raj*`tBq6@P}p|$GqaYnf-3p9@7`sQI?&jw`)m))IxsJ>RE9{Nxh`~uJx>2w z%a~a+YhsFo2(sR=_QK#m1Lm6`P+>PKH{TafIQ0UUA!t982ih6|z=Qdfu@&TvQQcar z{O&aOqG?-sS+eAU}KnLC0@Se`L3YJSkywSz};7hKb?x_ZNcF0QDH1jT>l>iSvM>4>ly zYH3J!E@&$3%j{Z;)1by!n;I7D{+j|i$UG+M@`)800vHqh6KQCHp-F~hixio23R|^y zA({X@oGPq-ktr3d$7ALT{odx&1VGP;U)3U6`VbCC@?V#$t)r`y?%%V|tFpwV%4Uvw zsIK~^#$c+quXKATUbmt+^Djqg=3e%3??1ZZ_vz%KR{m;0LY#h8Suq_YQH8FYm%y^9 zk_MkzJ+0%oFH}A*#VS4>JFT8mov;+=0!;yr5Zt5Cc?|f_;Wl@9a`S=3y`xKZ$@P@V?Q9 zyq@{pzLVE*?lQoQZJ1_&XSxj}EKsw7S7VQkDlLy8Q@>}Gl*&b`u%Ggde>b4;W2Xbf zTqydYH5z^jd!Z=}9OoMq1NZr3<@^qV`F8tuqU%_QICj0NuHur1qJd+QWjRT$OZH{)N1IRYNz?_sBiBf zQHYl>3GRtRzys_7eFbbJ{g)I9nBCnhKya?`WuJGHS%8aJg=BOsy{1l0?bBdX60)~% zA~Tes`wVCsV{+dxi{0Tb0um%`61GzVOAD3P6tt_k)XL4`;&N0YCuS5qHl{VMiUUqp z$>hxQyP=o@Om0FG(Fe;qR>?Wyc=c}>x@i1U<+*1x3^nnp`5s0|Dgv(RxeRqSX5U}* z*6CKVanz4;Jf;drgueskprPgr2rq)%ux~t#`$c$HWd~${bHlcgWpnI%`7ge18ZW^} zAn>dIvc$($I@1d~`)nNGfIl(a4>Z zwqQteu*XgAQkBv$m5!gg^)N)w;NC^Lq6b_RK6u|GPtoU9_-BD$yLSEl8&P9TFO#~{ z(*66s09EvY!**=LVyk7`U2<`QQj5}uUi|A4;(GR;;2D4X4*dcDTy7MRb$pGomTQa7 zc8AldVq`}BmOH=RHCEzqCa2AXA%ta?AJv0gQ$hkjHSE>DSSn`>yednkH(=s3F6Bdjd!duh3H19_nukdpWWMoSkHO`pA0g%T36Ul< zwd4-o57Sn?$x#z490}8}+l~lH98Zl(=zdldYCB+jzRr)2weun`ZG-bA$qg6_sd( z8wK{))|Zw>ma>R7=dg=`OMpi}4oaA$(msdA1>6Yn2UZ^s4U?l z-5b^Mo!;bNSRvyX8if(sQrAUg%V|L@9l4Wp=h(pi*h#y}|l|RaQ;;(|aBBQ1So& zIB(z+cb!_8m}$mR$;10?tOAnw?OGau3P^EcYeL2dAze9kz%Hd}_-(as!hY2KWq30B zrD@8f;+>Vb)vN5PqbPg*4IQ`Y((fM@f2Ek9m8G)g4*p;rzG4zw(pnr?Zo<)65mLONbi zRD}HVZX0E#5)C`q_6yo^a!)h@g;=z{ligyTr=0>cn`cv}sc)d%{_C9Wo-I=6ZJ4WE z$c|w(x`zGSnaODDEs^hN{ez&2SjVAc|I*ng<6Re=EkA3xV`W{+4quxw1==@t*GR?{ z${K<;tX|yDKS?uXy^@3FRjU^W) zMQ5(fuu^tgR-6W3)$jwo*N^P%9(LHqwC+cwgik-D+KQ?ZFM){4%b&RW*{{>0FAKVY zzvfDz19Dv?g(tsvge0EH<9&6L$hPjp+K|<+QIOC3)Ol&@-`FD+%RLE+KK#tFV{6wM znczmg?@?@BduI-rj=|97bH4%^F3}6Pkyrc8OZj~bo&7Q|%%`G{TX9&1)EUTA(Ht6f zLbV{)0Ntw4ff&0#Y73y--;{1W4S}l!n6d(Yl1RlYxOIt9h&VpRV(r9L9~hUT=+M z-WIJZ$ZwzwU5e9}Cek>P_C>D(i!W6%w2Z0nfV1bntU-*GJ9ex$AJt1uGo!>%CoUC; zc(+1*NUiruvH^P;9AHjs^j4M)g`q#CziT&LtIe!|W%1^t0avGOay250jma?f2dUz! zgQds=eQr7pTiO(U<~gFP2L~nlkAW>m?O@wURz>?=`>afIQzXu)GpF`yN0PmjJWR2r zvcqLDGjWTecv^7vWaUQ1r;G(n*2(6 z0ld`!$F(4=czta*9!#IE;nUYEp|3%pPw6VRuMA4LUd<w7hxHOP(6=v@~_OD?69CWd4#Q428c zu?Av9^*ooaDNvyNjq@p2C?#^RG{8`b{WY*iP=`!y-zx8p9=gwu2j^~@|9|t9mFR1XR2bB40 z>>Apqy_6*TaYWorCTk!1$V+p*u=vQMafSK3*D2(wT&7Y${?_@cs-A!-=`gML>uc!u zEQ?V;bKa1ioUNB!p94LvG|h-$?;5XtLs(!gw~!Nk!|fWZG2y#)8Ci1X zw=2nrUk=|G#?ve)n>-KW!To(#-LQ(P}VtcAKc!=a{@ObV`o+5fYIGQWgA4C zoVe9{9-T>C%k=wP{>RoWa4iHBP+Rk zz!He@bfIfzXT4jA^Q-YYrO{Ipdfgx@%29^DiaGkd7kI+}ij-s@Y-PoK=d+P^@K z)N?piNR@5C(nW4Id?{)3+wWqyE?Ho%#gvF%4{k6z8K(R#HGv}HPas+4*9V|H@Sj-B zPbEWAD#a&>u6txJtxoc~1LUGeN?!SLoG+GY@Yx~iG}J8JgcWdBlfZkfb1`&DwX2Mp z)i0SY&{%D_bP|?ul6mzrk+vzXdUJ%kaB^O8F~VoFX-0H;YynJ4^IMyC%qV)V4+s$@anGi#{$D34m1GcFT+Gmq5= zd&I{PTShk#k^2Rn%^zEtRn!BuXW_nfF1nn@Z94NIbD49{f2#N2alMgBtf`kNXLMgG z4ZTHp6ArW*+;C8!a`{M13V9Wg6w7(Vf;T9_ZEzs;q}Xs{uo;S^{kZLq(kW|8 z3b!#Y8JlI%E8$2QborP=&1^r@--Lyv}!eY z+B8PNdutvKOux*T&rd(f(TkDHpx6x6?%4gfs}t|5ms7t-Vf0`H@=)oc&H(G=lbE}u z9WZloMaoe2Oyb_Seam337rP>v{O;UAj>n=Fjo%B;OKnV~6T6tirAwuz{U)1H#} zLnv>;jOMeyI*M=asL4iKOgBhe8~&c}HEpui0L>eIbZm1XO(W`vFtM|-&{oy}y6U|q zS-P^QaZFX+1e|*%yM@Ey>ZJdp=`H-CaKENuq(eXf0coUR0qO3L2I*Y7q@vPpPGjnEUS2!H}rpG`8?Z)MgYM7Fn@pmpp!c8zh zoYscx-~`~#E~2wd%?}he{UxF1dD-0A+Z-=ri*RQX5a33|Pxj_qEQI;HyOYY>1C*X4 zcmPokrU`sn6*DpQZ%rcY0)-H03=V)U39W$u?g4HSH=%%o5uf4uXU_Isc)ftxZ=K;} z->`7ScwR?Yz_^-z5FhVd7(#5E)XyL=D7EgsR$Plx#qK3_*yu*Mp^|SfVC6i&h!ls- zp1e635?@E^Hmzu8X7hWmDr?XM2|TxSs^q;srfLI$Z+$vQvan*U zP=AHSlq<6SG9OG7b|^Px0-PNo1ujko{1TAe3=1$LoaD*>MMSjQjRU(UxBu;Gl}g8mH68rb`%BT-X9K z{X>VK3cnt`-SAZsO7XP=e)1BO(515cWv_j%|G|!e@EKL% z(+Ht=N~F8qDV2lubbmv#Q2}m>MssA+A%ndA`o@NW`}LAtOm9}3ueRq<4l_JZ2gf@#cD)bF7J&)!GL~3Rm!9qJ~ZCKCyA;e z2>aBoU|?J=lGHvTSB9_NWAlE0SwA}a@gXcUV#B(@_>oT@1_7qKd}e1Fbjj2VHG~g^ z^nCkoJ?IDTxgQ3G*o6bp9t+NBXb1cLt6F^uM=zLXZIY7aCz6vdnW_v3ZBXGLH*qBp9fdws(jD-CdP{FdbE3!OeKa`w2A`3yZ!yThl{b}Z6Pqn zLP=%I;+M^=_+b8=%PZq9y;RqpaUaF-x&q-19yVQ_^L0Wi@U5C5dyRRTBs1DQvtRFZ z{g@TUn7x7g^W|o*&m{ZYYc$}lV)+I>;V6kben}w7eih>+3ZZx(yEaeF^$o853anfi0!_s4w%My+4ZtH6~W+dVS1QG zwVj>hSW;me6~PtqkQ`-aJ(i#Knj3wW`;EFz=*JBvlNI zw>M7s?LTCEK$-@azq4*3QipifK~*>2yPAie-U=k`e*G{7eHD$QM(D41+~zXATiV^M z9MGi%6$tT`l$(7jeRFI>Rb%mQ`yJ}c1@mgY{o5I&1osea_#LCvy+k0!c(_ct@V-Ab z_r{p{Vcdmj`Qw}hjxg&;PId&ZhS1feKrKa!_iu>re~!W*_@FP7!rK&nCl~yF1Fkuz zY=X{fF%mZ836TVqDG!|UQ6jBYvube6Hl>+j&(2)1iL#Q#YKKA3UPTWF#9NQ`D=6Jc zW_@?D)zA(9V^KEm)y?B&tjix~m(h_YMQ5`=Q+<(*y^mRXXg=<@SRSosnbrp5-WPsU zLYGqk?L-MU$E)sdecj1}dzb<$(nE~O0(b*NgB=`W7n<4hO3OqbP+eoXsN7g;p=N-SEz3kX} z+}bB?HtA%$5gECydHzdDmZ6@*bO*d>eoG~2@@!+^7~)$^Vk8a8i_t_A9S$=tuNmQE`|Yf~0>dA|^~OFM;fDtNlM1-I8AtQRT*|-_gz5}G zKc_z42wxw6efhW3o65UKU%nfcHW9mHDQ;!Iv^eW0`%M7dwtE?|;vvP$O5s)Wj&?_% zN<^J4<>5=oXR@E&$*p_0IVyZd@t$*MMYoJm)hF>cC_`-0djAjE?piva8)olbjt2Vw z{v@|9%Wvdun&a`GXiKk0vyr_~jXCWQVl-+F?&U&iUTb^+e~$=;40`^cZr=Er&Y&!fO9Im$LJFc9NsY-g(h2r=q`0Kf0>j zeAb21C4p@?_Udm)OH=lm7k+HA2Li4jGdBDKpBD$7yb}i5(_ju2jb3ia&B)n_oE*Da zcXBJ>sN)~A(yf)%|7#-1T&wU^2E1q3`e>>7oUNv7s!Dkks?ptiQda!N ztMZkoqRfN4$UM<;*1iheGH+ngEj`l`I^on-9qmi4TH5w56X?wa&jLjt z^U|JYyd3oJgkyieV%!i1NWDoVd($#}o+OHmL;JEW?$y+73g@TvhQ`(Pm}MI91eo8y zW_XM5S`}totgv7;tq|>QCaT=%a<}YTj^yNU+TU7__92b4{SEZyM7Kb}x4_c&M@g#Z?QGyNH37qbhqBjZ^R&;G>tdWM=4uAeZ6}$l;2cAlKP-yG6>|19M!m88v66A^ z1jpq7{q@5u;Vt-0o2cnH2(|wuG=An>g9N?av(N>kL#t^F3|4Wt)e84=yl4)3y`-W@ zB<2_k28S)vj851x`I@U+pY-b9Aocv3^1Gk;V zC~^+_PR{i&hdn0;`ugE6bg{GVCuABD*p!i_Zuh;qO6cJ9cGCn2;RNMqB;e>yj5I*; zY2y6(CwNC-GO~bnK#km-vQ#w3u#9$Ozw*^>ue#>qNe>7pcDR(dAUNph{PvWNfKK0-QJSx<~iJ|M1_30!ZGWtv5p#fk3r=!^BU4yGc?p%1pk%vvV6{ zj=6X34<*NIJc=%T2tDyO3OL#>%!j?&;jr#3|KAcSl$h1&eD`6z!vWAc(dm(^>d8&M z&6g4iK78Ha#Dj0FNwVaf*^v4hq)SRHCK9At$M`A1Ml3PEDZf`Vu_MRyMSLKvJgqGs zP>=hl<^fIjDf|vg0H4^@IdD`A%UGlkf3_nVfwoTvlYEHdQHc_jALh>eA!qH zq%>F!0F|#1HmU$aCIfN=lv_9)-5{?^hry>n4q_+;be9)C1d{!o(=DF!*vrM9#`gCVt*zQ1OAfc+ZQlmE4; zE-!|1k^rNKHBpZsR$z_D7N(@4WITV0-)HjH1(gNnmH``XhXrs<>GyT(%*4GxAY>~ zDGGvj=DqDMLtjMq&$0qK^pjP`j?v)-1L+)=X$>sz5q%(}^Vy;1oWRm@329KFMrF+@$RObjtj&Ta9YE9$J9r-jGgm;k#Og9+?*Px-;U6u-a z?Dw6V#m3=?MJA-TTe7Fwe>WnLYhmtpq#IMI5PYzhaieiU)#r?^T)>hx$$^R{s7HZh z>K0pO^eJ(|W++GU;&(sw9d98v#j!eWaAK?u1@Qk3aUrRU%BqbMxfoYaQnuY!IC=8$ zU>j^dGMAWb;0&QM$=AGXecMEuDzyjMO%B!%hQKmkS#9nr?9&E2dzg@W6#lQXVK2B; z?06z!>Xxj&BKo@&;T}B(oWE3T z&>t0psHMf{)P56Xz86!TP!cJtZB1+(^qVQNU%B0$ref-0FgyJYvnycQ5sc4TiZu-eb$OTFY0<@4sA(n{BKKZYtvhZdB_5dcQk{X9$MIsiHts%D3h*E z{<{_H3}VkAOT#5U4Zlyu27$0s$ZEA2k+Q==AUWhnEKb}>J?(kmJ+7y&Qr+JtjK$v( z2pq)&u0OS-4i)&RWog?{I(e>FRZ;L?*IhkRK0&MgAvI>*zD9oV{oEk+!R=h#b2KrB zA7fOnNx4~x`jALHxR-7d%V)4u^n0S1&|C1eUFzXua;Q1h`K!z-+{X>{&w}&H!_7xv*4D)!(%R(ct#ZZ zDh($`6&#eT_exhGqu*xoY>^kzg+wpe4MydBi>e=1uoXnGZGHeA=ku)(kaXn%F-UwQ z?wBrM{kl%uk5z>fjNV%{aINI<3{3ibcPzaFkQ!d;)4lexuxiNecci(!J$%-m2uNG2 zlLS{${OAJpT>3h)j?bcUAZV@h=tw#d*cdsMH7d?fWruHiURJ^yj!5_L~`zQ#Q4{Yg(LIA z@1THq^p3uF2e6()@FjIMYle;`M7O==H|t0z$HOl2cITwGi}ey> zd&@5$!~=~(8vz2=i{~(O>|Yi4z)+I4k4{D!QX}a{c z*_pXBM^*#%$FtNVbawDdjoIQ8CZ_WDneY@x0qY2#F3$kFW$ZpLgFsZ*x&HUFlS`Bt zOk%9n8@9=Z*J+pL3q#*Rm~}1GhBtEbfWm)#Y04H@=HV{4kxy4jm&!5=?x=N2?)C9& zLDbI!U!hVTc7Bj|fl~MD1+}fU&P!gKTUzXT)$=-uC! zHB>cYc~)4o!&BBPQuYuOvT`*AX}53`_b^M5e6AnIlrJni z*4s@ZOy=@DVFpg5bG3P>=sXK&>KvQHb#mCB)(`S6p>sb-7}x}JiVGdusAZ5CzTRE3 zF-~$_aei+Zh$??d<**?Xu%LK4T0D{vXFMO>w`}+AVVv;IvZnCq_|>vE^AlUZuLIeL z<)7nYv>>GNSq6ilxYT3rVfGA)KD@yeCiL*ssfJF+wlWV zfy&I~a-;cgrPHn$t0st)R2peB+%5V$;x}Zo z@~sMH1_N3?Hsn4=tiowFg6;w?XS*CLkNsH6&FJ+Wd0L?W|5Cz{5)Hf-D|ZhUQKv>< zJG&ObejA~{8Fj&ts1U9*E0yCtapewScMa9B+?_!0_hz3osqt1unPP3+49`k@Bj!>% zBS=SL6?RS*DO_G}v&D8M{GPX48(iar4^#rSh}LfLol4|bDA zOSAx*`m&Hb*S!qIRYSfyTsA%-@#3xFp^-ykQ58{t!PnJQ@Uj5ZL1S4`WZc$94_IQj zw^#eo7sm?-?6!x=M-~fXRpa2-b}GY^?yz{U597{6LgVy8>E)mx^;`oyD^iXYfq=`b zTAEbR&r|*adlD-{jgDb1Fz%|{CbOykCy|o|k(xTE!l}f9y_ySUzTXZlY{_eNQSHL; zBK6$)Zp|Y=v9Uh5cJpCf%%;tpk(aE^EHygi8F5_?rJ4CT>iRUWRfXRtDi6=g7&wBHw%RXm)nC`$+?BX~3oLP&E zJ*{zomhhQqrQLy5!~Q>m*sn>7o8_+}lu0^arN8LeQb%uc)d3K)8q&&I_%`>^JE z{se@bYAkaKdbnW|A3w>Jy*+vtWLZn(L< z`Y+MV9m*vnsEU8EW=zvY(ZF z_T0!`lUkOSeuX>CipG*>E(8LZ(}-ZD4vGIp*XKzS78W5T#)LL{7PPwkyN7NQQgn#) z3Fm)nM7LhnC!fQGUT?zY+^^15UzT49Cihb|Uyb`Yi;frEGE1Miy;PVvJeiS|7`u_Im#gU&q>;w4XHSa zQb4-(jerC8$-v#Ggy0ohUUXssTRrZ5ON9SZn0^pnm(fjjKm{ySz1omtpEarI(tI3p z+(POX#^8}{Tn*0i{4RyUv!acqYow;%DTakNcMvDNZ-CaS!#GL3zE*hU(%W(OXKI&@ zBktwwY0b|yA69cB|GD_S=iF;EEDHJ~F8lWCwx!E|vj7nBK*t*R_{s!7EhB z;@_Xh-K<9GqFoEom)jTg#T%F0SF_|$#WHJ*p5b$Zqqa0stb1Qc@ND&_%=6=dr3pz$ zIpBlZpCZy@R=Fcm<>M`;lcc|l@M*G*n-4u1M*B@Yujcw?3cU{h@s&BcA}GJQA9_6r z_+A7Pd3Ar4@goC#(@v|xYw;5nJNa=VYJtd%^^+-%_D6~9zTMB(L;p?mY(PPY>-bIy zQLI^w;c(JE&XtOxjt7&(bl+6#kCUP*4}p2Y5wy`@PYQm@F4dwx zEq_~_MXxU8&;Vdi=9@N>G$)#Bl_S?ub8=TpTx{X zS`jW*{LY6Lh_dSb6I@_-%M94?oppCcz{PMsr$h{4XgzklYtLW1NQi5aq`iJms&*y2 zN(#J-oZv3zFY)ublAATJP1`NDQ!F?t2xQ7G+*D_SxU4Yji2CmgZ8Wk&_t!(Ie8#a< z&*Xn}>>nkaVat*)VCHhrlW|JDl7e4Haj!A!@6P|kfT*ctkRsd~kGPHcs_6cW_<5y( zy7MBC7b4K$Q{Q$UJC7k($vdO|mm0?5-IwbU7v@gC#K0<|uWf}mm-3VWJla{h*aNM_ zZgZpsb|$mk4<$N_Uh4ui6~24FT9WLFo3$uhUSr$|x{&!XqvMb} zPOha$wrOgQMQ^5B-nN#${>J8QzbH*XS;ul$RkO)PxXfkXO}BbkAoksy2DFeE+|eOfQeoFlH;Qi3BQ|D-j!fCTqb^Vg_$+Jbtd6lvuZT<9Wt zESsxheHkP84opns(E>W;MUtj-{v6754_7z#FR4YX3Ww`ol9l4aAaPG|uDw1xUAykD zoBD5)&$RIrTADLJZ#|Nnq!$OX{|0H*2{j$wo|j*(uHpCseA$*`Mi*023?G%Dfdh)h zSiQd08FsrzE+wv9_>LBAxDG-US4A#7i_42DCLkG&9ADO=VMSM#EyjPM#Hsl{-k9oyCBOo41Hgr4&MBfE&Q-s*^d_&Gjq z-XsJf-@QpT4DyM=Rfx$LWiuMn8&xX{R5zEC+9{<|apCh0-07aaI25)Oav9U@v$a)Y znn)Y4id|^BZnfSjM2^!v|J)qC0248=2HiFLgpyHA}N{DWK1H@bMWE zc|If>5HyUu5~w1}1G{^;dwHIG--Bdk#{3vs-;@ux?^oBsilKd~S5(IgBg5h?&YW|$ z5_l_)_fV4F7BnCX@~t0DMwuf3!GkGl#7^MfQx{JN)4wj!Q2;?b?J-HiZ`e%OE3EY# zUKzCwne9h?DM1-pdN-UJBDC%r~tSadJzO&5gGy5kqTH0C}98 zx%-Cqk0jjNWb~mLP0p>CUFrvs+4hufle2z*hH|Q>EAe(qFLdRmX|@81-?YD};V>YJ%a8)5828PBTfqu6?+)C+MA+Ka9k#N$axN zSOWj13)hR)Va?*yZh4s5vYIpTc}07DFqE?=Dc96yQcC;>n-zOl5PTNW>d*~bOk*Tx zkEn(B`*!+}`dE*zy~%MA6NjvPM#i|8G7q%QOC%AGKfPWYRkL`4RSKM@8nwjh5)R{P zCs;9HM$nWqF7lVa37Y4Wm+W;XemF=^C>kBy=f)Jnx1gt@54Eq;ae@z&y*+;LUACMI zKQA?YXu`!gQvu6dlta!4Iro4GYiqhhoVq zzT~DAe8LS*266lz@;3Pyz19Ssbo%Y>*Fb}b)$@rJlDSiKX0^VQq`?=_qpY;5mr`>0 zs2UXIOs0e9o9TFhUy8Px$e|_gYF?KV-0LLz^x^Ky>$t(%!xsuXXx8r2Wz%iSFnN7h z58QQYbLi?hTkN|J&&xm~q3(meIykgUsqCh%u2rrN#C%?Dw^P8|M+F@+pFYVHrel*;eo~t~e>USrsU+jOq({%~n$EO2Sd}sI zC#a>^e468%_05;4LpK`ys9Cf(6vX!XZe%-q1)y~Cu^|)exs(xt`hTeM$P+%M`(E5g zs;)2LG;sF)Cj&WrbYMbP7R0k%lOjyA-)_?O4@VuDCLtj!i`FFpT`nMPGELq(k7tI9 zCmaYHIp-kHYg7T7>haGD-+y04k>07DUn;P4O2;bbmr21-9cepZyXe01$^CryeA}{a zl{zX242<K&Vc@BJSnngBOzb~A?fcNq_7HYj z%~?@=)h3N*+9i;HWv4;UFI0pw>Xw52wdE@QviX9^=eTh(H9s?Pz;*r;Gw7D95-77HWl;xJR#p`MHSGjS<`>6MJ_ofkm4QSHCsx-`ny2<~zZ89Nb zCUV|I&DJcai9#fg>fs)TMo9}2?xIuVMGi=Tvg>5!-s7xG6-|W;xKho&@@O1YQ@JMk-d47M>{-o^TWt^5i+G}Qohmrm6yOFSy zX0uzRa-|mD5NU$zj^{s~l~go7bLX^kmJychOziFZ?C|FY!8kQPzOAc%Sixts3A#lN z$d_Z=0AEKYC;cyr*>MaZmH%$q9KEI5k92v>A7((Jw-!5gIU^GsW=7BXfqyKx&C2kd z-HiNV?z?vFMuU8v%fbiiR?(}nO5Zy5K&FBME?k&q5aHr2cvgTmLcj+rcn_A^5xu|Y zpjPt-SwIwwtc-WoD3k=209~x;bOA`^(}|CWPrupEbOoGQJ5sW;F_G8Mt$l3+-^w4~ zT+DDoc#2p8HU&Cwe2jO_9(HUCJ5(FZ&~dj7x5;WaH;#X;!HFO6T?hO5gkpgmlYJw_(`isp3)fDpd^@QEIwSp-bHq4NwXU(F8C&U!KrMJERIdd#2r**$VOAVhK&v z;7@xVOfL2n)jr^8IGIA%E(_ElK9|Rwr1FuN+LWhR8>@|znr7B>o22KkUihWOHPCdS zBe;E3fiko5cX;Molk*2}s{4h{8tuBuylfUb_UP0;`;YM4T`qBLyixj;*7mOhu_D@7 z#$-~dApjR*dX6zBhNB{i6XpTAY^@7(kK-xydYC{;TF43kj;XeXLmJz-Y`C4-ANrs1 zarBLbN%dGlxj$WveWsqLH{m20&$Ua-Lu1tXLXzqnjwK1L!>&Qj`?Na#3FyBU+)jpl zKgiuCTR&v~v6htIXoPB~yItWVE8;Uswdl~{3O6)vz?9G!(xY{PoDyVuVPe3^jlgbQ zdU>&%lp3Xl^KAghp?LS#0u1^c|1vEuE{-l@hz$&qe^Op#4T-ktlhbVR2EEq)SmQS^ zI_?|%x%#U3Y55L)``gJ6>K9~DCDW*K3FxHfsD zK;dyJZ>-uvuFERbZXssz7T_|j^(QNc07*NtjaKc*ad!d1f_6 zF-a7-bDc&>OnY*Zt$}C6)STmJlZ&5EC;sO{nwSs}pDDhfPl~iDS zBxzjnTThT;OVRNQ+bHeKBfyF=D!Qnp*iZG_d;S4I<%d}g;ft-;)2@Gauc7b`C#Z># zBADL)Chny}=sDj*NQ}rh2p&jg_j1eL3Om1dzw4)bg$Z&?kl2rdXS;9CS~rQ-Kt6_S zXpcKri^1Qn^s)>=aOj63Iy%lcmxz!K1AY=nP#60F3p-Z$SgBWLra~B-F}loX$aei3 z@}QbpMwS81?ATcs62f(X1@QYT2sniQ*|?#V${yTu-~$Gv2$g%@jK2M#)tSN8W?|{Cy)lY7&Q$C8AMz|HR+1Ps z=Muhg(*|1uGDXyVCG%f(a9o}W(>p|8z%gW=B=7bscimDf7qc<-W}n15pL31-Vsn+i||LktAnvYmU5szA! zq3t-Iy~1RW)_mkWX-RbvEv-Rl&{Q^js7sqX@#SoUZN11>W#)Fxbz-s3UZ4|z!S0=mDUa=9#L|PpoOVgbl;%=x5c0X>InGg6aY!Lnnztvv)G*_47+k3T!y=cTUS+lODWb`i6&)Ht>2;?=6*AC2p zaK(Cre^)jHEe`};3cWRS+X-&V8u8KWBaC0F*(J0a;#`jtG<<{cR)@rF@T&yx&nC~K zdBIT;I|1{mCswK)#Lv;hich1bkn1NBywa4u<}7Vfg9EQG&Z|V&uCq*o69e0p-CT^C zy+iu>n2zPpzaQ;Vh5~1d1YUI&+_6ipPez6*K%%Z-xMn?;&LAXvybnA6fW)eIcm_PU zH)25NQ{f?L@3!-i6$CLfsB|~;>S~d8>(>tQOz@O7beoP~Of7y>oVXjm8(Y%b9gFdk zH=-Ch!rCy-y3{fwX8}p&Z^F3-(XAe&eNUn+ag{3~tU-TDsm~B^t#LQ?O2y2fN^x;W z`j|=k1nPej!=&_6IBdRuUhD*I%)IH zwFAwEvs%>U8lyjt-&{1!wh4sAppTgHy8&aQ1e6<&wQ$r2?~qH2mE~(yO^;miTQRKR z6ew-9wsj8-AdQ3p)^r4Q@|t3Rj{kYo3lrS&hR~re^O8}hmOyO}6GIH(NIyKW7w!RJ ze)VrOA?Vb#_=mrIv{5N;QW^<16CMR_1tYBN{gC5w$F0_;`QYtbt*5LJ!RAAC0Ny%# zaqsWj5&zh`8O0UE3)9Z(xt=DHX?=LSM?Pw+=%CM;$U3HZp1_D%r8ISrpXU8;+mxkC1xA+2n zE0JFCmvz|Y0gSUfjI;5Lb6Es&1iq6rCGi8^c(gc8WY$bNp~oC#Uv9f5mCW*F&K0XJ z*6S_2RpYIikXXry4N2pxGn{pQr9;Ee-U+z`b*f7)Huh6-}!UrDw%uHZqr`8yW_8`d5c~ z2kHI*hd$ccpak+-ve}dY6{v;IrR`~~5~9(Oi>kA-Ko?x*FZR2SSt;ZPUzPb^7kdMN z`wzI^++PyH)`|SR*#=(@k>o8toarcdjK{qe_?#L08)Y*@lyY|bsF}MYn4nI1U7BUj ziK?d@lUW{NQZ_pa=-6FcJA9y|HgDk_Iz8SzY{Y1ZUl9RJ@#bUki+Efx@B1m2yWy{9=G-NdPPI2+ltW)jUKtmww6U zFb^as8%^^aeSVW%g250!(_k}9yFGz1R9F-8mtNGm%qTKTv`spQU-~1Zdm^@E3eKvv z*8%ZwD23OQ-}d>}Ux8>Y`IB@UPeAIl=9Q=@>Knq^K5~yHLb3#RQtv;NX z?4t*3$#X+bExjj#WVD!Y_&A(q+vwk?RQt74!r!Qm-?o9F?}lrP@e9N^JSEn)#`BzF zrqZSNt<-ppfC8SRs#gN>NS+rDb~`tM?+2-H?RxI;Ymz1m2wfq!Mz4eLS*rgGH9q8l z)78Qey+6*J@9$eF{Yhzc-`>N@S;e!keYrLyOLU$H6*sEo(J_3fa@12ZhgL7ln1lj# z2?6?wEdY!fZYq2pye68|)LfCf`eO8eg$wwRSPZlOhJy||q#KQM1p_LHE8HAym#fO( zuMK4nnsB(W;R;f)KKsm2ip#l`#9`i%s#Ih;>En7-PCxOj*QIJ`LuVmH*yO7S2qS7v zB&^JMd$%1IGZj8949>HU@d8Ce!xG_j!7pWBU^53-&IRvqjdF!LjY=#*5gZ$!ag2cC zh8(w!Ik0S_5{qQEub0#0aUss@z! z{AGVBf&F>i#FhQpta3ivWBa^2q5>L|@7a6PVn~mA$+0i#PV&7X!a=ZN zV7BDs^e6a!W3T$5W0QVOT9h%BAhO9PdA%maCL8=ai9ZADmpW~lq+lBBP2d=cDxu1i zi}KNP?P|vMjZ3$SP@5rUuYL#QiebRu<1In~+_3=dKgr>nr%(2Qe^rlbviIVPZE@VX z+0@c(6w4l~V&4a`x-E+C!*x~8qdP8tie2yg?!a17*y4gt|A~MMjt{a}Sr)3?J80Li z+t_H|+NCnZvXc_B@X!vk;kEyeM9>ZDAWyA+y?!GtxtJ*GuX}d^G&~~WR3|>gH<21@ zmO{~nTLHZdIzrbGE+yYNVB6hI3*k|tH-#_>FmfzlYSorg&?^#|_6M<9Jm{$E=7LEq z8Ihk2v6cG<$um!jFln|7x#WI1iP`>M<-`3f6ZGk8hwho^WWk3aDa&at_mEs(FbJ^3 zyF*8mJ0QBzM4+O#=YXoMP+-iprA`xD_aoDLGUPFCw%GM!0nKgP@JHCNl*(!s0IWcO2hViOnG>=Bj=AGH&h@Sjqp3d9p|`z+I_KZvNMnTX^I>p`0Amoc;!nSH?;;si;Yf;#J6L`ri47aoglE zP2j z+|(yQhs>RO#2xr9LeCuBUm4 QP)szbfDE`X{cx+mxT28K8JxP0G8nw~jDo-tO7p zKW3_N4Wha6hs@0Et-?5NcaO`mSDlm3X%~5tXek>)82Ryo63((}Sq?UdH^{)Y+H3po z1P}|2ZV(4UtCqV%pe@X|15Yi}yGBmE^x{*DVS0%0I{`2Au1kPmjY|@aHWr~)+l#U! zAgYdA)r>Whewb0BaXqS@``u(F(;dv1S^of~X;m_n5b-F}GOfixAmm{%o1y$+5DuZnPOW-n*yujn(K_ zjbN(&#!#h1bB=p{ccTd(t3m${?U(g`4`t8l$TFV!?a~OO#zM!7sFV<*$cXahR{Ic< zmP$2PCn(?FsH+XoDxsxF%4|`rHGh*jqS(!?8c@&SIW5C*MBJK{y5j?aHx0h}h8w$& z3D*&S7cTrSxpJM{&#iTJ7XSuilt_4azZ0tS377;dy?`atqg5S&a2=hC(D^M>s9WA>Md_PXwLIvR z{O%vZ&etjr9Ow3O8r@Do#|K4r7djk;=wg=tVxbz9=q&>W)w^6ls`r+*2a#<$?^Mpz*i zU94sXP>^@#jxjsue4mi!I?0zFx>(1^vL4-`N=RAkC57qG(MiBi*t@%ybavtM;(i+H zu65^|(Y1g6R1drDehd|?TB`xL`U5k=yVb>0B^jKbKBl#%z08oGrMrG@26eZ~+6NrW zmXhJ>1L9I%?mI?yQ=#P;yNy84QnY?lv}IJ6asQo*p?{atJ#j{d zVK!Te!%^ZWpM@EN)1RRcQ&LYo*S9e?>){<;eok1uZO#s`g)c~66vegdC|=owp8yDG zXz&PSxLd$X8oP9?Q3^BC^#EVL0z$yE5kr~tzkR+gE4D8Gvia{+9MQp_Rlb${!W>#ObT0-wvvnDHA9Ag?H9(=C7I!&v%H~6|r3K|}e{hZ5 z{ikvpgVif)yw#3%_!d!+TmyN`cQqZ@qT$eMr*`kq6a?b=H<^5iF>&cl{66%czNYKv z_{%bC*Zd-Mar5Y9TC5pOrSMoTz^#Lf?|$rQ#bZ5;_TNT3tw++$bl_Q1F=nNWkou`sdz+oHPck$yT`Y%M|#<8alZulV^PxheM|8Hm!yNuT3ho zAKadvg~U9Zjz0zrAW}NuKNacIil}O@M(EZ(wk__lRc5%*Kc}h5<2M{^UtNu0_$AD=`h0VKt z)pupU5%!nin@Hw@@w0K)s0b7i&^eKGS0hCa4@jlCS8&#d4DuZt%DGUwOEi8|z6%Su zLtDqc+thK$ylmgwn@_(=i~G_ZYf0oyC~c`Kd5D+_)e(|>;4kv57|`S9%%!FdhI&tLS zF8Ke?9ikkt=V)@+cI~MgOLQ%~|EBxYW7r6*ic2;hnz@8^uTfI`AmM0!~04(pN#JkaRB}0BB5|z^ytLbi_Myv)5t1E3`hVYFXs)1NXZS zGQxB%pTN(OLohhsv%ev=%}LQo!Xm&AHlNTs```c-Z-}*>{JKKw|+I= zdd{6ZR1jvWZOzo&oIfqs{S*wQDH~jz(o^JDah2RuyZ;TWz~97~$E@p+!ox(MM)H)J z5;5xj5?x8CJxh}Vqx~k$9BNYlp?FJkv)Osk6|}{yL$^G)K`2q~(;8r5GKn}1HM5`> zOcDlhTsIO$K3*qW8M_9vwCY5TXAHS0 zS8Eg1{JyIuv2dVyxezet$JMhsv`K|I%zz(%&AjlE-?W0h`0i7VY_&Ykdvi0*J%XfTO7%=|QvZFd43}Ns;Q7*y%j!s+h)!YHV zk(w{Njr(+CAhbJ~k{3F)ie>apKG}b~)BS=X=H;9o@B`)OT_`*mrq+6AiL8O|uF=ygr8XcvKg#YoLQJY^8NFR8BUrihZJ z#>F=XKaeAmYfe2?K@yW9a37m7c+%EXzW<}Nd4xa1lu#D3U8IDnim{RPeXq!!_Qfew z-NEJ*->LjNa=oJ44Z^$`UJYJX=K+<{bG=cvD%a^zpxky-g_3Zd4&F;5!ceT1WIIjv zl_bKImRDkr+=Z2VqvC3?{#zwm2Ca@cJ$hc=PDs`gC>6u$gaG14e^L8>)>BAyf(7`@ zx27oekICbieeHdKd^E&&*X#E!!+KM)6YC5;4>*!C6Q01X(4S&f*`*D*b`X6w^SA;B zOs-##c0(LrZZ=(8#$M%!OKxnpYH7_zenvlJLA=@(2-e5PnCi&~*whK_lk%3{&d`kx z!ECDSG8|D!SHq#nA%>!xWDAn*UrFEd|B|%XZOq2}+U&f@3mG3U{K(mZNttK9IATX# z!;bYF?ezj|E;^4Mx@6PHPTXT#?1O!q%%m0t4ua0Yi3)>zD0)l{ugc5=v zJp`2wB?Ost$4HT`(T$|iAZ#eD(mgsw5Jo5|-5n#OrN4vE`@Y}5`)y~Z?!M1;tzFx4 zfd0A2I(eSHu%97xu+s|#6@-7J zNRfL-AK8DpTJ;3kp{?MK&*}6l!vWJSx zfUJA`U*6-h0Fh1Lza%$ea2mc^ao2V0@{%3^L<+83F2$4{7X1J zO7lfvm%RDVF~x7q9T3*vyJv>ldLI1n`za{J<EF=bf?BB`e zhYRb(Rutp6b#F#ES+wQTJOAP4e9*QWfe~{M(jW2Yw|Rc_)YVrWJM&?<{3G2Z zC9m7+>57AH~ky)Is1)5piB*K>HCEHHAm7b+fz?_?n+tDpL+qjoB|{KoQ6 zE<{gjbG!-i5*78<&si+bE2<_oA^A4ur$n8@mKJM9`Z9@kHeNuDcEG0(Rf zUF2+`ZHy)<{EDDkdh)OPwUN?2`hn;~>J^MBJ7a`PnA@h&O6!V(p(uTPKrEm2826XG z&nkENYd7PuiRd!ZhA#e>6@3cdTo^k>TSOSj&g^zy78`!l60xpyEZCNOi87;tJd*f_ zEL{-acIS~-{~mFC_8J+;y&Aug$MaaJzj$eLyy@V5u1B`2`T2JrFVEnh^d4#tysBsa z4GX+ZDef)cYb!c-`*BswBS$-zk{fR=R^5E>CG6%&x0c4PjpOp{1v7PY;bbVK)UbzJ zbBDhC9y1lZ@hQPHJ^D0{K4u`Ut9z`n)0CY|M|Mz5D2Z~o=3IplMXup#h(uL?Fi-UC zINv!;Kbu3<u%^Bwv=e`*xhyZhO6Si$oanNU&|hCXyO*SXzHqN6LoxsjUu4 zWl)l3sc_Jvk;&TWw+UYdJWl5=f8r5&u{ zdEtG5?5|_Yx;8c*bKUfRauYN}@UnRY8qA$}lo)ZjmiBD`5d2W9P}jObI`UV)31Oi* ztGi7)jefXd{i~A{wBrD-W5q1Pz3z<5+C}U$yrQ?|BGt+;n_JqU_f+m3NAve@zX}mv zJ-xqdwlO@l8fmVIynggq2+d-sN*J_&`H-wfTr^Xk?AlBr5d)1mXa&@{& z8gJBi(e7ZJ=Y2!O{823fq_im^jf^?}8T7}nLY{+ZoLe)lF;9E=ox0NsO@ZT$1Jl|> zt)4g<817x-Oic9CId1tBvz>T+X<9igktgQvK7no00tSCXXvq>nbU3s?>ub&(E)7zW zHYt8l33DH9y9?t?HDoG5wl$s>TkxnSZOA(-VY?#x?SaH`!ZGM={DX=m<0XCtCv;~$ z?Qht!MaaY0=rNBWUk0j77oUNll!y|=+G7u4qhr`nLS%o}hybmFo2ifPT6^Mz!nGKx zy`{zkzZ__YLdW)PPYD-4k>7ZO6f1M!{nWGn-U%vD^|H;mifTazUBR~ zmt9#^pZ@#M7L^HY`>06$t`~$9o$}mY(yZyUkr?;b`FuBFg1S_&w|sao=}Jfi;>e zq49SK(A&4wkEI@(DgdZa*!vCZ-W6$5ns!WKYKNlV;Oe_!K3(&VgtYO`j7CoUtZQHG zA-bH++5(nrOtMy<42$TJ(uznil)Kr%z7p&D(Xk7gRh|M0g#Vs&*HK7si(V-7@Y?5- zWEV?J0Mu+GaF?>*4(`ud#ml}pFqEq()dRSFqgHkgcEr2)_tDR869Y9eR$jPYnM?WL zelQM`uG9yKWcMNh2NKt3fo3ZBl*v!)d(%D2dN$#kUUq(h{AI8@)5_J(<1SZjua_%q z5sqR-sVV#=lr6TLuyI6(-YL?Ot(ej*Ym{y33tx>p^k|Zh%_E-LNyLwAG^-pN zQQv`Istr-DmtlCckNtu0Fa|{&?)FO0FxzawS~b2m@n?1I)~3(F0YGYff&2zz0i4I? z-BabB>_oN7zT2TKl7==jphrJ2BN4!}4K2xDS4Tal?JC&;I+0t+|M+dsfuMo=oyoFm zee;&UYnzFO%n@uzXUmooLL6&$#*{-T_%vsj4N(2x<{5u==L;n^cpM8dIN!HTWcK7b8b3dJfK8a#?3WdqNuY0h(6}r=O|J8vnFvdpN(DgZK-^W87{g8@71-eG^+T^ z)f?pYZQXZu)1_WMlq5FD#;RX4{3jxPK=sr7j{WbJtOg}L!sTy#KC=Kg)KmaLzc5y#GkGXc(C-$45L{|V*lnIt zJ?0JOziDUE%3c6u@#f68+@htwA&eT+W1(6I@+#DBgWtT?ycVXX7F$cnYePT-n>rb?0Hx$Nm7wa5Xb$ew-@LOagw#yUi z+p))VWZqVpwdT`YvMgp9r?=fpp|Qj#ka^8bza^U+<bKdqn&63sJ2oVpKf^q>Z4{*!Itmn`V`8kbOwN!5RPD7 zkgyL-J({4#^r*=6o0J1}cMloFQAU-lWUlw-oYvD_t1)ZOH2lZXLIcGT+V5VH|KOjw zp&k2^43Pm1MaI1hY<;(8XtUA6GK#}NP#V9qh*lUWGRzsoes8E4?HY_EDa&a49`CK5 zU9d;l?dv+wU{mjKAl%=hEy1g%@pZSe;+HGo$7{L_>25#QpZ8XL(6E0{5<}H!ej(x@ zkv@Ek>M%pCXr>=6i?OI`Vj2_82ajZ+8O!F?banhcq+E|htS&`yA#~S`JFfw9D&mCt z$BH>?qJ-LzB^Vumu$p^VNbabwrBWGr|Z_QUs2;j9a0~ubX>vfktzl40@nL5C#@S!8-Ef^ zzq0-=%gqZB>dxb+9xRqmHLTNz^R#OX<{NN}9TByOEL%(bO>ls-rS-xyKOln=VjBsU zg!}jOUh!;7x0?=?nJdnXpPr@MDJI%3;E=Pd+;RDf(`a|A{|wiLBwk zx8p$QW0!+P4CzCPlZcq0<|mQwSeY)?Y%)8tc|%00E|9?I3hCfn+A zv1+S2pGMc?MTyxY{Mp&JxOf0zEu8E-JksUvNmh-fVgtaF1T~m7L}K)qiu_}lnAIC$ zrIBwwFsd*-=>8 zMZ2$%oHn74VGJ&?a69e+D<+NbXoP4QaNCS`l-&Abpgl>&3RoXlQiVZ|*2k>Vs-+*U zcKsR*=`XglZ^Y|~Rmzz97VUe+pAOAuQ+nL1uAmob5y52beJ;6WgkkwBu%Zxw^-4FO z!Zvhy!p=6xYZ1xM0V&w&fUrfFc;Dv@ z!(c-A)_EZTgpP0gdc9K1tm0nf_mCw&lXX`APl`D#0ib9F22=^|k0$SPUI!|=u%$^PR|fRgCEiBN#JK~kvdTLc=<(-k7By_S<`oLyBzNByo z_88f$r@%IW-$0MCS=#+m!Ehf||A>|zqsd&qVZltllGnx6{V)a&MXlmQnoqo7xUDV{ zwo9YKo(MHH#H`3J^N#3Zpd&T}1qKge)J2o`%p6+qRe0q%zV22g>^SheO^p&k<`!-@ z#&_t89I7~eMXK#d$(7~G7XN7 zePhRDa8=xHXb3gm9WKcc&J#(Q`#YLl2Isqy41)oxO*$WCL!BpB_Ys!!g;F)^U5>0g z2V^}K_wMHGq7l{6>-y)*T=;ri{+mhrhIU4&kHuG$C`l>_3uPDLr8N!Jg`4j<gePs%yZa9Q$N1e zb&A4p$5gvC7MU%gzibjU&p|+J{(#BolU6QnJ|1F8!7mCxd^(yK*J?MGrxrFnqEF%J zvaa6b{G{~f7X)H0<$2t<5=VWhoO~#09ytAW*s(tNE9tGH$y#S8eMGw&eTod<+H2eE zW{8iuY;CIDO!;l9;zB5@ftsz7HqD-(?$^Qs2NxBObNJG`+!enG5fZzq9LxTRh&9vb=WV$IRC-9nof*w`-=UU*6w>xO6?q4{ z$JhS=yuMKWMbBI4-x1;_pC2t1i4g}D2@Ys}{aY55!5&3U_u>_=g-fBjVf#a3yJpKo z?V5+jBkL5sR<#jLL#}CivAFCJ4JlQN&531B$0SbG-n}cF!AQ}hsUK>kP~oSwuk>O8 zlp0izN6cDPl_5tA>PcU5AC76)#LZ`Y-Hq;C7zjjDCBv3RwhUn8NY7QDLNxM+?lD&m z&v)JU^v47cAr@m5UdW43qX2`siLsZ7;<~l{aL~sFF`$uW8qTxgl6l@KJi%J!MR3Fk znT7O`F+P=CJRa&%QJd!15#=;zmDPRFt%FH>B1I=Es~f;NvH?{1df72)NPXpyA z*Re%}r@d!tPl?M`g^h+i?_fvYHMJkZpC3oe4e)g{3v37Np?lto9U=9RM_-O^eUtT= zxKof?I^1ZUD3=bn6z3pSfVA&L6W*Zq@=I(M*%e|g)PNfY85bz4(p`ttH@WF@!uJ+J z0(n$)avniS)f(9yNJBQ6MW9!=`Z;3$$$Rv8k)> ziebiW<@UhD71V_B$;s}Mbbs*MvWFCCw9WAC~;)ftjp4VX`WB(Pbk*D9&k1~7t?sVjb*HXH&{g1P_=Wt(XtXO9FkUZ+Nf5; zo+*qcz>Zq9v9aMA#<&vVQaMO7x!|VUNuqUM$Y6PRSi$J0E8!4$=4b-NUL>^bt z&?J;sr#UF;TuiItrvEg$(SDahrR#Zw?knVqYDA6NBjlclf0uT9dE)6HZWSwX_xP?_ z6|{d+59+_<8t=*H6~M16;XAeeG!FaPlUvX>0I-%wlL5vaKst(Y%S|brtmJ4c1x@!r z_<{WlFjuf2HU7GNLy=tMFl(V}OItqVG2q!vvP}ChE^c!kUVL_-HEuQLqP}7Tqk&l^ zpWT(SdrZzt?5P#0!Vzx${1q8wSmX^;&-&Y({lk=R8_1IGmeYQWZbZmX9`Os|iV4kh zMxNJQZwnS{`!Nq?;XDs*zH2l3+dC5J-UaXiG}-t{47R#R)pX@3?sV3#dboLR4M-U^ ztk6N(j)*P;P`EcilZO_>y=1PW4$WNM*qH3A`p>2S{JhQEB9%!K)k?%w17j78_uFwn zpf|E-8@EZ_i&-7#$*zqM%KA|DT6gln2zR|{y68==6zqLr?chUrI%1FjHeF(OjjS=1ThXZv-ZV+&w9I5qU3oZ&6U}Raw)lP z_A#+(X3uwn)!`YwL;nY1`*HL-&0CF)vFj3UwDF?)unjG6Xz&T ztFRcYIep*%C?Qy(n?4_3?qZWddkH;oNQJocN@hCNKP?;9@A+Mf9ZS^KTY2?%g!99+ zvJps>!6txU3SMazT0h}kl=}t$6bZCX0}uG`bT=!!HHj8 zvekd*VC#%^W*P8-5`Db3x{bDwm=a&GqOLVdCKk9Q?HT9p?cT6uq5m@9f-Q_2Lu!Xy z$^7wz{|ur^@uE`Y&3gwe!NcNn7uQDKe%8t5sU3J^IgHQU*Zf14Rju;W9lzXBoA9*Hg4cRW1RJ$Y1b!#3oAlij>EJ`(F9 zGgGIO#hb0Vn&+S*nJ4{6Zh;Qc;$a zX8sokXa^ z!kK8At@(n71+`oDLqpgh@mqzRc3qFNh4IpZ>PllXr3uwdQNY+GboXBu-~Nc(i*R&8 z==CF0f=$%`nbFlq-(4c05N2EEYA@H%x8@~}13|)s!u)ch%%R47zHNqi!ZK7|jsNCI zxx1U&!V^sNl88t|xeVZ$HQ+7}`Q|l4k6V1UiD=WaBTNsWvy4b&RULWNJvfx1uE{?3 zId_z;%S|{8)=~~N8u6qXX!Ez!g-w9;^|y1vOk}CeWYIW+nEE7wx zI)%>GL^W{F^@=Q;!o7Q(e~?J;)|)&>4oj+l`gjRYa{k9S?Glpx{#pVi$JFUCmt>=I zr_BFK!VHpPr4Roukck2soIm)K+HPweW14<*iquMSu78S(Ha)~NW~L`6pHycq4RDCc z{6*J|=|^`kCE#hpCM#qlY$i-=;S2ExX;Wx()SI=Wicy=yLA_zln7L=e$2K_XY9hU?jT#bH9f$81`f#!J+j<)aXI z(uYP4=&W1^qAGXoAE$|1J7NhM!fVOtMb{e68CnmLQ-Ld;7{OSW^FR45jbeu*y1JY*+u&S`Hu_3mLi&o&^-1R+mel;#Wx}uM z(+k|Q5Omkoa07E}4w;d=8z4>EsB&!M?Ugs6eUyzW_1X5e(P7J*-ALGEk2$J7N{;c0 z^9K4V?PQM)Y%BQ;Vm?Wq)8zY;>FM@@uf8VMVOxk7ZUK4n;uS~N?2qU@;f(5CoR z@h-2eAwFp|k>su$+#>rhp;F8=5_o8@G0cXRZ3E~n|}RE zaS8i(I!}J^zYx8eptAqCti6j5#%EPY;>T0ii+5wT?uM6XZ8mq6@8i`g=m>vvW$clG2HtD3r%NTa22uuIr8K2ybdSg@_R>GI4ED zqS9t)yq&XpNnzPpL?e>t{R9#s`s61rWY!XLMdDIwSP`Wzpov?HmWF3}T(Fm(hJRaY z8HBoB?VwoGE^2xMTuhHc^IjK>fOcOd|NhTyeAY+ywzeW$N=k1Ci<9?GY9_k4RjFIa zS;CBkISEWgV$X1B)fiPuIczUFkYpE!nqRk4}iiv~i=u?~8i2XKi%~UZR2i zbu`&Y-1aN?UaY^!zf#Ah-!{ikHT{1=*36mqCEb2>od`tD-5*J;8%6~yeS9N1|8eQ$ zC#m$iYBiRMRfKKTWcnG_b6U{kegX~0RIZl$Y5eGz?2LfklOb%~k7JzB>hzg8uvd@8CvPu8*I5r0;C~ z3{ZZvYs#W?O?#qg$?0YezA5rMSuCzq5d}6Il+}N z`%~&XPrDUoV>HKbuG};szO%`TfCLQna^HX-yVwbS{OfVT)Q($f_|N1n5~oj=a@VMl=+aj`t22+GnA7cb z#u%QuQ7`+)IBeG}#7KfGXgt;btH`*JIAk9ov!l`>O4I$M9+S?1KbG?=C;iH0>v0jG z9l2G{equs3Y5KX)<4>De@}(WHet@A?hiqnYGSbzV!fZ`D`dG#EosN)<@!}>oj&?*O z+%%I)z6hby;$X17dlWUT$PxLBQ97l_x5Z{exB_n|(~f=S)_Nvv5eg40-g8JC(VJeJ zgFtNlHHS+z^{rvOALpWM=AUZvnr#WVCS>Zop3_fXxtehqp8D|JH{uhHME(L00ND-XiCoA*E754?g^hcmL7AhD}rvDD{?RCt7Aop-&V{rfauDH9Ys$HFvH zMz0`JZ}lcVLEa!{_<`4QGAT9ORk!lmn1}?A+R>)_ty>KV)Y~%@ZMJoKv1v>$`2!Xrx5Qii1}seaV=#A&)gn!T>LuWrpzL;dY6gtEE6 z8_mVkNrQw+Kos@2fqu@*ClTRkbvfH;8FfiVTF`qRA}-c2dggfccV{&+G03BHhwk-M z3U(bSuS2mAN|-s8Ng3P~Kg(4ecgiXJM)ZiW`u29ZbzSedRng#5xy&f^?T8)}UuWzE zde=x}mX0~N+Z@hp?c8&$gV;%Idkqq~SDg&+R*QuUGBZ3hN&HiRSd; z{IKU$Yna`7H^Y+>T(&av?1Ah z>!Sb+nC%^?Ty^}3NFvypw^v6?b?_B{b1G!idZl|YRIIgaz!hEiv(J`%gg(u5FCne8(@hR0RH;xmUQr7tI`Oc2OIuqEX$a5F;DUQC+@nKFOKPe zW`p#~&%x8)qPe)C9O03=aaX$R--6i2^VW&KQ6aIFT=ZvRfYA3r2=nMIT)mDK8pltS z2(i%p_hj+RK#P+6Lt&>cf5++5`E;VjLN{qiH1tm<$Dy7sGJ8}oKlM4L2}{fJVR(alze1AcXo`nJIITPiW8g( z-PO`aG*@$XF@r2WC=8fhqqqTA0;F`>@|JpdHmlXM@XFz5Mn!SuvUTR~(c}g=kTB8% z$Av$5T8;XloI0pj{FjjjL-pBe=D=JOQi4(}81BsD8E^4fXE>vKBJR~#@;F0?&aUI8 z>~g`~isgPh!1PSewDl1wMe$^w>qu|m*p()?vz&&D05By(O4a@aioaH~-fPS`j|CWt zHI|hD4FxIW)k@LsZWH-BHo25gNmpqZehu(8{O02MZ6@-A6+5XY*#zNqmZ!83m*@jz zX5m-VS{U+U1t5j6j;i@LD#RVaUH%dMIg^uVk!d;(ks)wk;lgKI1o4^g)3 zsqMwOog##leD>Ux((5=s|LB757p;G`GMH0{vN*t4u<(pi`G9frGvFL7o!)PY-VDFY zZ>7~SAy@E>Ci-*&2?&x3Wyx539Vo9X#nFWDH3;Pu>UtgImB|Cv$Mvrhfr-miO!1nh zQ-coG8{JY_I$f|p8TMCuXR+U#p1|$LC5|?jgWsM4nrO}EuJ*+-8rj-A?STLa>(vg8#BlKXNAo8@Op~pf}2XJ8q z1O+RS(Mh^FJ3m&kVbaQm6AWY+THW{zZdM_~)!_x7)hS7FYyKH!>K^3&U3`!MW(=|B zma|=z&yl7i8GEyO=C^7yxljb6@jEOn=)Nu!;#H2e?kt*mCHRr=&{T_84EO~N{iZkw;0*O0MIQCfh5TiY}}2v@3_!nP58V+2FB6AWS>-%C^sKCP#M?C z-767YZ{cQ4J2D*ycS?u#Uq@qcpDKQgpB4GrC$SKNGB8S4!eoKr*>%C6OL}+DU^&9? z9ItuY1}Lwx5=}t`*hPLMcNx^xWi7HT$q<~9*S}B`@kCp-s95%9-CjM3N2|K5CY z*C@%ohsrp$%ytN_oeBO>>-+GqmiU`%L}edd-WG_2mtkc_PQmB@(@L@Gh_{OsC7W%V z`Z8QZK?ug78Nk11;Qb8R&2jH{Ny?Vj#NQSHU=uSRVCOWBrpLy5uK$M1nFxe(&90sJ zgFn`43%v%345u5@6fv!B!Cdp}RA2%h-;bNaYdCQYF-M3B-nUaZeFnl4M-2d*UYNsn zo5CXJ;C?;7!S_1y^qowvY4v2h#sl!*OV4m{jb&9p`Dov%12_Ng&J)exN4eBMA3&w% zs(GW9&^&g^%^buKAqB@IT>j@Osovh+IaJ1D?BB2X6h**>wT-5}!hqcZ@Lg91#PNtN zkn`M_pcCY!yQOXM)wx{&PCBXv_R}$w89PJQ6fkQv7G&3W{owQE zFmEUnXu9iHyrlf-0gU52+zV#OfKng;=Gvi>$ zj1$3+EQK^^tkm8H*^pRT4>+s;vdL<}OZF=KPjJq&zmdy%O-;(kTY$Mt$=}i>&L2O| z4;juF?iO>sYuL2$P((}zh)Jt>e+LuH)#)TF35~|w4Y~7R_!r0B83I7#3~7FQ6&&1o zW;FM{{nmug>%7|Tc`z-AiWKR_o!<@4htJ?jSx)T&UbNTuLG_eXjb5}ZnpG%r6kG-5 zo1&IGGG7RL@x?%1WftdzaH zn%K-g#=q|=wnt`(2#K1FIBGLQv&M9AK|ihS_<7T173(H##|b3dd-3S^3e#z&?eX`JoFSs3qLnJ$-+;LWiXxoSu;>roclj^XiEFT9(YW-)OMco1$`FWrW~+RF zUD~6s&(#`JFiys|v_5e}qaWQ1v6R~+M@dmN)BT?^ZQ*;p7V`p;$jtMdiA2Z+2rJ|f z9+w-p*?Y9_@#^LxKdgcN%NnUL`1raNUDz+3%j^>mM5_|^Z6ghb0)y*Y^n1qSX-b-2 zFrpS2wsLFE$_ZPcG|K`O|s6m!(dqk@Gt3Xp}-?ZBm#c7xBZNd5%+((%!M!HF>( zQ3^p|_|IHH77C2g>{lABvlc&OKgOh_yK<6>KE=W11^wdt8E~s{3+zbLiMUE5p#tLy z_--V@)vXAYvsN@(mZmh$OHLrwy#H+!#H)~q`Js}uhk-jQ#1nsUPW6znH*SH=765ro zk=l*U_U`@N2T{GUV>H?$;Bfv&rRV?bzmJO!6ucyL$PT#=d>G@B)mS{ZWxr;fb&#Ic z_y}4SND9quDmd*8lTyGakz<~OtK|G!n)=B$j^wH|aBTvXu9#cnW}&KpR!#Gd8Y*wU zH+m9HSAPS<3&88X2eWjw)_pYK&J;3g?(RhYm>U;j+-TyAH-fQ>EdfF|PRpf>5<=5S zR{0^N<3ODu5%=GKHyA{Xgbg>gis^n9IJGl}Ug@y&46|0ZtTIMj1oat@K&0#Z!kph7M+=!UCSLIrsm@J4?rn{^ublJ?^f}os%*@+pX$D0b@D(z zDT?U=`v`uctL)yxoM zU7(C9OkVnAiPTbB)@t7iyRA>4wxQ#OsoMaS;y=4~Bjd(>&E5F>qIq0_``vtRz*qt1 zbKc9R!^hDx(6CLkuA2@X2z&UGS9fdHp-T`zD zR^hXCLd4~AsUo2@C240KSDiQtS7u4Rqhv!Y2_kH|lwl}yLm%jJFl|Kt=fXSENLo%G zr0LJ-&4jD2#%I?FIl9yufsOMnQoOZuOwTxtqX*884)V{5pfcQhi)SGIjyjAlgYy=s z@&VbhRhYwX`1_$dkk6qlVUxmL8|Q}+r1^6!2lM$>`$aF2Q)KY=Rm$gUWocb^>B6&9{*pyOo zy~~P9XgH)EXZ(K`5FpeDy|GnagJC^swMqW9=rp4_ZjRphZ8US=SWEsIj1rzzh$Bf2 zF($lsZ&1XACT79$;!I0Cx>e-vgY`!OV!6}|xnK#1f3`&9`6`3}Y7D39n2U z!N1I@6vk{`A?@fkU+%NpMpXaF6Bz3xtwM`LmrK#gTRdu695VYTc71kQF{hKh`@q?) z9Sw79T%Vs*vXY{j{dn_#w~MJ>l2y=lZzvUCxAmdY7ttMDNkck&(7#go@36juX(i$;0nb^bBMQZ!}wEql9lN=1W{)-0a`{L{^e zBKfOjqn}!+0lDfLOia{l?#jb_M3rgb$w5{4G*^4%nGwI@%fC}xvB0c)vksLDa;gew zWD-jWAg_hd=(CxiFaBQVyHdY+SKhCT`dspm|NcR09qcI+@qib{_?6-}>wDUaa_iV~ zFD-6LD)%;dM+46G(`&CkA7>SC0iV_o6Y+4{{kg`e$Kcz|>jBbNWYYG^bZG%EoLN1* z_%rR*%5^w`ucV_nX?3(%&tvm$WI?y$M5QDbcZ=c9dbN#_vAB@;7QpaZDyaeQ&@qzp z=x(SR3XUkdA~ka}`?Ctz#U(sX&@f&le^lT0V|t;KR}TTvT~%$Z?$PTH?eAQPv{lsK zqg_THbpo+j|0gz%vdUV2g~V`(%gc37M3hayw87Thm%gEcP2sB754%w=V!t{145q^i zAmtmk0JE1i@fRwV_%xr3y~!e8|DC1~H3~n;D{?gDpO=<(KkoE;BI}Q{Mr!Ys)Q`Vy zmCpCxt~0|3U>U3jZgsa4y7d-IkK&Hq!|et3TZrc0wKSMpP)I-9?SYO_TDV>Nck+cK zJcVxW%TGJgbF}+s!%7|ZnQ#sP^A#dS;!wsx4^cAhxoqgZva;=3;&BXHZrOlM8s29* zG-JGMRboPV{+W}Tq-PXfQu;OcZLVZyU8!l-lax;ErH9@1jn5}XI}4a)Dcc)t?XIr0 zQnvK-onBt1;Z;NiSJB^phLqm`NAu6?LQX@B$&H@$?UKcy*4B@n54L~EVy0x-qU$yH z05@I4&8CR_bEAtC3r2b2=DDCBfzB4a^)TMXQJlUI7ulD$9`Aoe4Z@-NHWQlfvic~Z z1y~UAkLsvB>Sble8n-_Dn05cQ-MV$Nq~6t}np?aZAW80@`CiJWeA|zYSZUdEmLvXN zO6u5{jB%FFemA9cHd;wxwlEj+H=<_p|IBp_bB>wX4#eo}sf2G_dW|cwzAKYd)YqfS zcDbo@P3q2j%Te6cwnezslz!+w&0QV`pUV2>{12psyzagihDV~`P6>7{GVJRd(q?X3 zHUv>*eJ|tA6ma>9ZKXhHvpO~Ncvl#s^6`BW8ukY)+G&dSNyMC&od%!7Ceo8~8kfL9 zGXWN%mn@1~+Tm~*Bdo|bUjL9kt||s92qmhI2sX+nC3QbL%o9>vS*4de%>L`ABX{@1 z50(P&4<@GG-u1=hB|+r+w|_sTIMe}lU-+LErp*tye9f(!Pt)_p%8@spy!iI-KH*Ba zoQPn&{(D-K1)Ig53(J1DN}^>RSx;_M-diNkRXt2Q@?)6p{m7|j}cxIjzwTG++Q9h%le+!qeCtJ7Sw`mXOc zln#(hv9-PP-7GMqG8?p(hh&`{NW7$E<*RT=_q8{;(fDW9;4k1)tG>zk%|}2@+#KGf z6PQ~1RTxpIb3>cFMM?Y*bY$$~7if>yEg6BE8~7F=bS%6}f+cN!Cy(O3RMqAvLa+Y%-hfn*f#ho= zlec!E^FzEsUZ7jvLcdqY>u3r9Tz;MyqF{UZ$QKjyE$_hZtB<8|F{mZ}@a<_+fkm>) zuU7sTpy@sYlmgHS$g8DnvYwMKkB10C6E34;&bsUT5P&Iw)D53eGoFY!gnXgSb3GL~ z-`<7Om{dtSS|IYX(&_p&NHY`e)f?ew{^rZiafPC}F}a^-Kp(bH4dnc7>#xogNkM1n zU36K-^>H@M)`+{ozlFXSQB1NUxpf>?4Ktu+y9>_@v>c_USnJ9VdI-XxgU^(oo(Xos z08~IeauQwO*tP3-pMy?Z$$yQj$egA9P*~z6jqfIx5tUi3!dCwEbnlP=}c<%$H%QRBKfDa-db5^~#c z`T4>E>@s2_%=P~JGKtVvdu^HYS56esl?!uU)A3U{z4l>EJnDsaszzdKs@GVZ^U@wQ zV^mUbuBH=!9guVXm74y|0UDYX5&eO$l{JNLvf6>Mj}RY=nV50YC((b#jN3+SplNI7 zXkr#ZR~kH?xhs<{ZxbS}^N`#uhG7vpB(9oU{AgcXzD)msfUB#VZ0d1H@MvbEsy!dx z*y&AG;TfX`5TgsbUVHi(0FtR0QMi-x^Rm!6Bee?4kpC?H6?w8NJFeSLw71sqgz(SN zI8xnbmkUB@no(e4N%5|dFu`>91De`Duy^z23;DdIv>ba{U;MQ=AWZV`fv}Oiq3Xyt zImTc2qTt1HrPDHUL&=7lSMf>LNsEY00JM|by|6N-EI|J-mt4)B-Og2n9Z7baorV`!+7RG|P&p2>~p&qie1 z9tD`xjenCm8&H}f{Hv|yovS9_{TH~Q5S^!4&l?q#bUCz1-m})rH;kF%IL%o;GhOF; zK_^s>_dcKBezVqjWzw%^KYoW`CgECmzg%6AkV6=4zJ+rQE|pOy2@@Q>m@y=^sroRD z;N)g32mxmAKV!7Idd2R9f`7>#j1m#U(S&&3Wg^y+qx-Wu`JL25<}@eC(V{0(ls&0( zhA+mq1XH=b=a^Kh(%Yu9Btdd%ID#W?j1nB?^O_=G0l>k4yi|qSZ)?4m#A(zpldg=i z#dy_Oew%mM{kXc0?|E*ZeN9v9kzw*M(+^8=9y8}3btJ2rmnZvUH{*dLT1fQ|7WHC% znq;w3v;@!ojnT>)b>N1_q@)Agl<&;>)|R)jg_CY4j`S3Hp7-o8!A5owr@D;x(Nq70 zFJt`SLwWba$$s6lzns{PcQaRp&vKjkrofG%<{f81(|OS>{#PX&?!H_G zAmh`;(_Q6Q>>DofdxWYq-rS|x*heuWp|Cc(LA$%2UlQiAH-+7>HI~oHsvG!RKjbVN zk8b-5h(g6hD!#fX=Dsq+EQ=HVJ$tm9_AaQ}cbRy(He)LrV8NJgA7t9RTKUj_gntv) zd1YlQVo-XkV5Di?yz#T_%(sFj@A3%1S2pfDuWn>HRldLdTJjh-nQ4xYg_X~>e+d%# z3!eV1q|&xyFDR{EPe=UV)So6j$~T3g*J~LdwYb^eCRp|1R-BRaH>yXiU|k6xh-c*Z zgwC7|P%~+~4NNUt^jlifxK#Q!APg&Pg&3{1>K_n6@o2729rCXlQu-L#O7Rs3_n|## zDb%{KOk2?H=PxyoU#(ksaLIq0h?Ql(PvqpmbL?_*G1k^L&QY_AtM@47BcQYN`Dsh1 zARoEORk8>kxaDIH*BJT00zjqe$CKdI4qmwmp9^Qy};rKDSva}anX zcLp2IizwCqT2_$f^4Y<)MaWoM^!sWvT+`2fu1qDbv>e@`=OgKCWZfG9`vxiR@EsZw zM-9yE%&DtB+3bqBSQPsy?!u&{c5ZqPCZdMNqbz# zpUtf!xq5uUcVPedXgGGIKWfB?z8Y0#A<5@YWh63lfs7W~-Tyf(1tn@`^S;j0_{Q)d zgZzezPrJCG|GR~whtiDQi}iK%`LhoR5b`crCjw*@$);^e>!Vl6vvN|X?!yphbI#){ zx%_9fg@qu><&R*@b_<_UzcbapX7E|H*#bH|naAh*P`t-kL zf>0N`_5c}N&h9=OMD7OT-&wgY9^-5o(0IWqXKz{|0+%mlje7GoBgTeV-@hpY`SPf5 zJ;RfE*7rCJ?^nHqJLtL0vS`=$TYVZ-w~MK8@QLz1I+aKmda(LYk+HkD_C&$Gt@z+V~DG$&4m%Lw}}QsFYGbNSwG)%T7R*WtQ;yF+F2il$~1Ps2QJ2 z63Ly10LsS~K99+E#2?yw>>(zN12Jy2ipqkmw0vTC5?mwTrmP&* z_L%Q*>e`sw2)jkhP|=yY-w~OfDYrwnryS<1^n(HlgqCzf^G`&h|OC zWOPwh@gUhu%Q9=+J?@RVtm0Pi#GWFz$a<24Zv{hjpx={FpKXmSuL&AAQu-b|GdaC= z?LJE(+qdI5bydR6pfn~Ht$}iD_Jl*=`8_<^Iu4ULwypah)l4$VCVtPqkI?4z^Ink? z%QnWfRdL6akB1Vkg#t&v%(mvNyH=btW2%)LQ${;q-@SQ@!?k$LW3M$h~O z+qA~3++cJ5e?d&7PX4Uw5qtjGT2k5B)3iF+&NE!&%YV*JOZ1)I{m^4Z*CBsEJmTrn z@Muv@H@k0d^{)6=B7U1Se6Lgh`%n1TG3bL4Sd{*hL|t3+AX%Sj^hrfR$3t=I6~*x5WH5Nux$ynjb&JsOJfL_WziA>$s@8czak8kPc~SkRGIz4y92-O1ev>ySp2t zTLD3u0U5f7E)mHAhVDihhTj>Ud++=H3-j4$?{BWP_C9BK#ZPuH1V%!s+$jDmDKHTx zKj-5`3A|_cI#fGmyRCWT zuE776vd;L8qt;@L?D-yc@|emS^H@FFJn{$d}X)u(|JJtH{djGTaniLjK8p$5@dY^IV9?^(2WdIZlRBz1k;I?xSrzWLkfRWmzPklPf&aK4%E^$hO07t0v-|)7n*L*b ztAF1}r07igp4EE%^9l;-+Z~5a`E4P~M+3ep}qYr(5HS;lTNf@eW{Uh-^ zLF#&PXv3oyFYNx%UZ^!X8sn2+W1O|$9nccP-~OW#=D8QqIwMW|KplksxY-+F*yh_g zvnQR_%pVC=^8HM57L83^>8w5}+LM$swSnCN(i_u<%vh5nzHtg)mBR=h2Ow{HE8 zV&)MMQjmh)1KdgZX}LuD&4881gS$@WCBcNnY>U-vz_I%&BJ`( z2f4Bt@@V6I=cYw3G;opd>)?I#in!qHsZ2k~Gf7(Cz2nQ{zPKbm_9kWfZO`^Z#?+OG zFI%(H-{V{`P@Pu3nrZg^DE9sH$iSP>;ys;4=ZcMuqBU4^s!u>5E5W2Mo;H4hs(0z9 znV2&G;mb*pC^N>T?_{eI2K(+d;M8+812!8KYuxYH5oCOBXC@g7(Twpb@2O1U@~EW5 z!ewR-mDvBp$pe*9q$hkKg;t+_Ur#;q_}$iJcvwgRRmi3M&EH@!?%nUV>+O4Aqpgnv zP8b3u4gH^+E7tV;zR%}h>5q#i$?pKJz5R|`ehHqUGtmtQCC5HT5!2g>rz-ka{@tV8 zz3eUeHlynEHxs1t1A*^gpY0kB*e`smu4ztZl@7ZOz1N54Ii0~D@lhaK0T~9s`)wL{ z)TIHr;1|r!dtUD*T+4U6Mn@}QIm#>FnzFJcbRB;DHqpQ@zPY>d)G9$%obolYCb1lGN=;gFU8t$R4r;u!nxC4!55ZiHW`>nVD@tytt@GWD+ zZGCgz4e5eQU%`u_ovTW2^zST2IZxLfRV!{?y*Z0=Z@=i~F%K+!PgjSI{v-Z_#ATCK z1N}`Q`Lu4bSZn-Sij1}oXn(WT%z8V_Uc6CH4@06yjw?YQa(X^g;GiM!@o5T%-VbNu z*yPMVC~Rj`*(PeVV0j zb~HvOS&rcN=;5Ax&>mB_%&RheegGPFroG zlMjL#bR-a5akQUP&hlP!>s@boT)*F^Vc-Qx((;rCM&&6b{BEXwn<{Ni>RAPRYZSDH zf>}qq8_}&&sVltUemj#jFWkkzG1&^-w@zXnkvZd27I2U6 zeMrMOqM7S{up_?dBQNiY}K$bH-0sNc3u)h}I0NQv>?&cQ#u z?@!RT)kT?gueWA)F3}2(Is0r%_X1pAo5R}Og)=2)tz!8q9WbQA5*eh0+ zk{qA5yG+DLS*UWS+SfRhw{6!rObSSdi>6YQXN?6Ut*{8zU`$ueaGzwHq$Ypq%2bKRnuZs6uv`= z^(6{?w!FL;HB+fWgZTc>!JlR{bW!3mWp;vM2`qRrw9aU&5z{1+5t;?yKM!sS?$IXq zkvB0;!bx7YRAl%q;C}0={*7mI{i)IU*H{Pf+zqD*-<>J0kV>B&z7bzv;#J3novf^+ zSDHy>t}YtBt*L=x*nXEg47O-(+-?r7m-kBXLf(8jS*T41R{q1Z~+A4i) zBhLst)dSm0@<^fVnoK;(6>qpI!o!f(OoDjnWa6h_-FPE|^S zIY46V2BGTEdj;S=$Ec1-uCpgHS#2T!ZvQT}-|IPPyji^J8hz?_h~KT#AuW3-kASz| z0n77UlJXZ^ou{$5^uu$e^YvuzA0=8){I~nw;U>;X%+_+D>b`d*r^ho(3^vXATI1iV z-%PrLVJ*C=;YTXM5$mav&@forw*TG5BxsZW)}v?-x+cJ%gz`_sN&-gQU@q(=B|?;dfkFxutP3SvLC z>gzIP@0vb&o{L`DQZp;J*JPhD@wB|P9>np_NwR7Lo zeAp(@d|zaoOz6N@6P0MY2ska{-`=mx?HyS!-?IZQFp(J=(IMP zY>K}#I&=45Ysb4s*b0o;bYGR~uO;1|?%xkX$fONM2hx4J@(#ioY}KLqHK zXpO6MIj3H|hz+@~CCX2oDGs}WQ&Bd&sHZ={;UQ$9>5(OFMlUED90eRbt^BFv#7qJV|9S5ih4 zo)h*nfAd?8cDy?9uFQ+%?>jv{wfB~j60EzKSf{BQiLrCNKI}#59}{^D&4uZ=Ex$wc zAGUaAt|gsuIg(f{1g;zQA)}XY)H=`~l4LmCX22@+XS~g`zgOYQ-(z?~zJGEuY{-BJ zo^IofOP9a(jYw=b-L2^t!llgV+}18l8_}?)8*WxA(&v-X$9x*|T*-1jvki zCQ5j%PBMJ&U+Lv31n~}N?XS_mJ^vFcv#K;Eg$;JKx98eBq}3QoZF_VsfU2GAaRKTs zAjdMK4QR)+%%4q7Rradgbft?^;MB7Pybb!iqaAbV#Rk;26}DFDwkN%h5GJBVo5#kDrx7Q_NHAp z?OiXzzS@PoF}vEJ`u-4ceraElb8$y|?UABpwFrND94cK9pPUL7(EM^EvQ7y`+NN!2@eb93_r$Et*I z6{cjwk4`ph?ko-l3#EWiLX7dL#VNS&efPv_p2Os7ur}UCIrzz$QhcKis4X|nCvONE zwxst`!^~i=<3MS6I<^3V3^D8VSDV#9b_e^P!O*((x~*xuftK2p znbfB-+$`bBy$|Ak1-AtlPoFuDy*yt5_5gMx$@lNSMW_Dr{}3m#buW5*|LdhEl=Ept zU8KqWQH5EcJJD(HqWgXDavL`O$DuU+Uqqv}_+97c=dVzv+7fw(+X%@Wr=0tgujIHe z)~D}ZvDaRXo%>(Ke$X!%XOYAJvlG*ENUK!Odoi zmr2ttP3zl>PSlyBWV(p-N@H$=#l^+Uxh2tz>7qi8M`(zrAZAGy0^srQUi4#)^AT7O z5{f*ZbsDCQme(ON^JI7oMEJ_Dgv@Vc@6B!O^7M857{IFx$8RG>zCk+Wl74E}w&E9+ z;1~y|y#h~WsOWHCSzQCX19Kc0^5rK|disx#!el7UKhoGQ?be?#D(x@<<7s@2dc`?k zoL%eBE@xA!XcR{vIo!>B$m{JbIG?HfbEwf&XoTF@@f@^rA5RJlFnx%iZQ)7Fi5IEv-Ffom9CJev!7kLuC^x!NQD_*uS{pno|h4Cc93-bLx1^&6Y79TVS2ag4St>CH=oTabOfiq5T zF@Pz>7BU#Bk5`rqG^sraR%gS#twBYlmv#pUnhM57_4K`Y1=>4RiiqQ6X17t@VEIfe zi80QFH9(aAbS5D>$t@)|LyvxNPK9Zl40u-Nzz<4!nFff^Z72svTGmGl`naH2jWU*_ z%$)w{o*4l2v)to={1040fsE0l2LKdFz+Y7KJrRswuWib4j9R&L69akjuZ&ZnGd99M5J#?cCys~=%LQiY7N=_!Zm z;26M1qvGOZAio=o={Gkn88c-=S0MylL_IV4SW$1J%YCyr<04y* zY#pvqfQRREA>;l@c5oD~_@e?YWttC^VZaE`&*)v&FsK22q4S{ERe=p#-o^H_3~#D` zO&SpsD%s5@+)8AYE9GeN9UsG4o7uLpp3j`U3m}z@NMS^ikH|fer3Rwj^4}yIj%-vM z31CxZ`E^f=@mEaHu;0Exl^R|_^CvY4iuBOgj#qLKRWO*yWRvile7quV{z3jUB+q7f zpaUqqITU`(y_qIfaDePU>}_!=*>C|K6j3XgRoGeULuO|HLm=E6mptR640Es?g*#ba zy{EKh3Fa0t?Qx6lBb$9tjyejxM{U9I`O=Z_nd`+XD~jwy}*1s)JhyWuhz1 z@UJB;Gz_#PNPG+`{~wQz^U3Q4(0|CT#pFA}ZiITyo~Dg4HS3oIA6b&wpKC=-JpS{{A-UXF&IE#>WqjS3226jP>^~8}BO;&& zYN)3Nn-Hy;c?lRkTjt%x2(ih>5G9$GqL8U%k1UuL26#682kk-ZkM?|du)PeK}FsMycswRQ~hnPNOwVKdRL9Eo%KHb7{=CfzO zpQqTK!5dq^w%gbn)asV!BJ3r?3WX#qE&-UW!{?CrGeeAOQu07now(Emj6Gcv3S?-8 zA(?8J^MI_B{VU%m@fv8BI1Fllg+m0(s*MMS44Cgu`aGkCPk42DghB|0(9Zd7PR4(h z?>QFoc6i~%%`_yVMI{3wMG7O5@Ev*nJaY2`Dt{421Xod(RMA6xN5#i*F4P;1CPw9Q z>iJ!Snhu3%`{&0RC?$#MxENyw*}4N@j-7%birhQTux5YNB^ zEJ7I-DKUfy91L$o3z5rWT; zJBbk9j0q2!`(PgC*NtRmL|&MH6SE%4b&-D0D5Cq#@3YB#KB$24l{AEaW-Y4p?-0z*! zL7UVJT@*alZi%|@;{9c3eE+QmA=|hDPM_PB*~{h^dp3ER7u;BJq!^AK9)ct$gyezn z>M;T4@bW9>LHhqIFsQxyoV{T{g@~-q7B~kz_hW|I-EH1;*xW5P2N4tp%BhGO-3@W! z8&UWp-J-M-*WMta@3-BvykqY-PeE+CYTlII*XcAkC*90E`W&P1O+ZL|rdX1E2;g5L z88+;YElkC!9(02*g2YZkxlB>s2!`yN9|G@UNQ%Ff-EY-*Is0w;W9xb2qXCvOO=>vo zl3K27i2j|xqR5@~; zo4tzlr;d`ikp$q1)3KNl=9tI*J>kQgSC1Ts+ivSbx)%{|1v#jw_O#%2zBG&hLh&F! zdubQY8-%vZ&_fW>0SN3;Un2}RT3ArfM!LsIIJmas$m|d^n9GkB!*Q;0j|ID%KullLt=$7eswQ@Vp%72y%!n z$NPyqjL`oCUtG<16vfgitADrz6UV0;uMZMSU`a?FZSjBWd~}?`Xl62>Vi6MqoXCIj zd+~1`8ZkA;=(E%&WG9}{pqX31EzS|%qL=OJbuqO#i~Xsi7pC3J!-g#={1FLPz)lYx zuI^HopTVd=p3>j!<8Sb&?;Q2FIq}jX2Yi=Rb=(K-G-uwjOpDY|k_7s?_WVW)Y#$GCsUbMpV;BK$j z_htatlKdA5$P^zRsgOSc7h4=w=hCR{C}&D0F^HXcRA zX8h%-0|DX6gsiDg1?@pxFiMX?Hg0Z>!Ku6ys+xjgaTd{&ukC=t_kGs6+C}#aox~rU z-s@mfAih1w(WUdaqzfd-pr*X(7+^d4n20i5jTGongNZ;4;=hS_P1m$CB1-AoqB4jl zTp??6HI0BBE#X$-NoIdkjJeyne6ZPb=3YNoQajV0jeTfsKwis`pI#|SbX=&fog^ph zkTRvF;}#bHO2Du}l>Qc>+ERkTsWLizh&|3b#(2>`{j}awYGgQu*Y4emBwFG7_qS!= z8!pCzfxN%D|HH@5bB<{L_Ydhxo9C%T9Smk;QRLQ_^VQUIKwQq5b6E&KajgDwy1YHC5-$)DryXoYfI`A)F(k`5F8og?x(}G*vB~1o zxR7cVbi={U8>lI>J(@0iI#3(i+SYKXtfjJS*#T3^lRl?ppsiz!vmhHX#ai5JMgpRz zq&UqPW-M1~g^_@x%L zRUR3-F+a<@I9xjvu45!rMN%N}4;e}%7^NmBBSR&=>U~9s zDAJ5|i-Ptg$#NFUcyG$AO}CE2*hh#cA3UVg@cuqG$5ZE548X`UY+E805 zvRo)|61$1sQGff~sOZIa4?g;-r|;DtQO={#1JbV5`SLv2N<6%r%#;LKkvZi4t8CP% zBN(6|RjI;IK@q_Mp=M+}7CiE#WBQtEM+$Z_d zfGp~9NM*1~Wyb=lU!#|@94_!LB6CD(4`5wDhOKA25@@QUSc8p)N_e#brA_Fs`{E^| z`8}xVOwI+Rwaa1T$)!M9QN}^GKvcQj=N>nsUDnzAt}_wIN-5oNq_>y@Z^2BQ#rcMw z{>oZQL!S%w8sIg*joj$3S>ir$a8ObW`HAeE#@5v{4gYUVjj!(9l+p60jQ@pZF!&qQ z2x(!$IBia;3yhC>Q*^HS?DsaxTVm{GF%2Y=!$ISp06eU|Jc+_E{PLpF-1iO8eg7Ac zk?JMp2C!TQ30immd;vl7w*5lr1jDfMe9AM^Xhj{vJ=_f5xNdevyHJ8!D8`H>7SHaw zW&Gjcp_W?%=CAYEc;r|DNT(5!yNn-2OO`8v0=}#EGew(d7>s9m10!bjw)AUqwVWJ6 zY}w}27yx^ajc>f{UD;jyyC^Owm`%}$K0La}s`}9nnAraS&bYW9J@=blC<=rsw#$k4 z#`^k_*vpv<+0u?xi%VRd5zeVogKoHLSN?=( zQ!}@8ZSafCLv@HuN5sQp+Z;{HzheN+Qjic?`x&`+_F(O?0^NteXGte(D1r@sOFu#S zAm=Ys|CGU8M_);1>e^R*sPQh8U>CReY4C{c`MwmRt% z!iwf$egDBwy526}-L27>xBMvk?(Fg963WO9WbX7CU2V;&L>Ws8>!L$e+*)It1CC^Ti{i04_uHkb7pmv`&4J z`Z6Il^Q6u?w#kG@q)rvAPcxe=2y;_}AzD-@UJPP6>PE6*=I7ux&oh{dQRa{;cG4i5 z)PqWqO-Y&v7EOHQ2jeFXFX2^VjYpO*)m27QPwHaM{<|}f?%;k6-0`=O3;lJ^-d=EU zx2zZU%tGToQ_4VTBu!I&plJk3#|E3p)QVP~bv~Nu8gwZ&bZM1u06Ya4`;|BsC@FJ~ zgXM1#gN;yKCy;)~!9uiab4;xJfcEpb1J&5kjnP;_>c5#ij3`<{M(JLl`U?Hx;-dJ5 z-U))ub66h33 zP!9nsReVDtg)-@f8W&VT;qURJz5W72=|pogP3Z?{sir}cA_j;`=@(>Yna@mp<7OGu z4X68O+kSIs(WlD&sw<$5Q*aw04Hp|32UETts0u!>MYR+j@OIy0(>i zqJKjL>`0KWz+RK0PPF7sXvuPk^_^{l)P$dbKMnH6KQUBLtfRNrys5gCM|b3iaSmzJ zwuiNKe3Nnk;fUYx^8jv4U*fR1r|0Dtc;JNKf#eJeMy7A0?B;p@C3%5k4_K~+Cpy{_RDg&7N8i!ym+4D0zlY;54#QB5*z|sKz7AYmLW&-qK859Q zbRVo>4HwYiX1oyIsk9M2^f=PZU8t^(lY8Zedv1k4{z<-tdeU+3=RM(7hgPJ1b@o3& zKg)6kS%?wmLrLuWGP?b+L&Fs@t0R#4?@a0nWjr^kqzF2PVZ5^*{$t)no|yH;QZ4frzfItyQN8KmRPH zT7~qeh`1Igx`-a^^Y`S6%1;LaYzU2Pkt9L0rhUTr*W(2xY!1ur|H?9E9^aJ8s0I4+ z=&0Ka3|7WWkxoWm_OSaKao_;8-cF7FooklLrZ>2LsCg@sD*Xbw0%e_P3TLnf*$Z%+ z;x?KKpj;uT?L(=N8~z3$#=@5!<(26JlMvd;tqB;i$eX4n&+MG`Z6HoYZ=&@_XZ>~j zGlrlwg+%mmcz;MQuc$%XC@_9F{t;!{Xg_U%QVDLF>?MK-XC7w-fmQ}N@0jA(}PUNzTg5wLbhF*=|B5f*SlF+ z7{h6d3Q`eg-FtS!+X0a-oUABGUKt~HkDp~pJ8o*3CSzcO$&8)@`A{BLK^L-x8&Tu& z#E8;6+|2Aqe`aB!!q0?U6QeoSY;&0f3lMbZr9*nAHQu4nQedn3YYssjO-6<3pH-n#U5%z!M3UZkI=QHzUwad-LDdBeLvJE_z@jylAG4Hb7)_%~ zl<*v4!4= zA4yiN&i>k=Mx6e(0XPGF$Nz*NvDTJq-L{p%208Irh1PfVxyu-H1b>LUTou%RhkNLn zr-l-Eu1Fh>PeJG+1;eGp8nxJ2SIXyp_w?D}Eq})zNk-eK)j#b*7NffVic$Zvt*E7a zokHtgz0yiglHUlCd3?}+PdL}FCeU!cGHXuGA#%Ndd z!5DWieq+gAaQ!hlj%0MY82hu&NK;%n6xw__;UxXy9Av8}KI^Fp@x7}{{mmgwQ`Lvs7Hkw45Z^J8R9`t zhvAsS0nhTBm4uk+?zRdZus>>Qs^D?iM)0ypG&xP5lr$7=g5Xckb_;OSjtyH`@`c!_ zXFLodS&p6lmqWl5=kc@bu8yD3nLJ4UjGi?QX@1e{NtO4|+@=wA%c+%=<8JsWev{=o zuv-z4^dAuw(%Y{Pk=7<)Atw)$qwzGaf!k1kenBEDit23`st8`_U< z4mPW%KX4H3*xlTcb?+Osn8|+P|Nie*K&6*>eL^qM;R?z2)KewT)r(YE=-H7!>aq){3~yxA)`;wW^P zC=i}V$!nA70kija)@Js$Z~A;7{t3364H4V=4cLc>=g6e2=7;UkWfzJQP8xAPzikE!Ts6;m#tiA)5z_ehv45PrJ1a z8*1rA=Po#DZxk>0Hrt2|kDqWEVvipanROnd#``l%K<>+!jn&iMDJnD=z+&jmu7l$?9#V%9UPbtA$ptA3DI>Ajo9<`$KN(fs;V zL9iP(J1eR-V&h!MwUmse`C?zHpTc{0nA=~vx3ZPo+SBa5n{tOFyL2fW{%s4u-P%u( zcHt8>v(6S=a$tw{?w=R=l|7$Yd|KiIVWB_B47kn$4zJ0+D3)T?vk}&#lgR0i;IuEKH%YxJOQ8yGiE3ERq8da;;y&L=&4|uV zN(jw3C%Z124JqzfC|kEutd0DEMnH_T4kLkt$)n6ptICDZ(Q|DmEH(^Ir@R$vqCV6c zT_wZ=QTcveX+KZ2_?nc^lcDg?Z?Qgn^B2ZXi2b|5*T+-hqy~dsYYI_uLO_8wiX1UB zgYRSvrdXTi$)LIuChgB;lMkVo9EgTV&-MVdqfNYabnd7LZ{K56;2@_>kb93tA%P?- z0JB&GNC>x|hxtG%34ek9#C*2tx1nirQEX=i+*O_FgZ(0nb})Ll7}FXi)1&=CMj{^O zX0Hsh!#Z!lm-ub-{{1Pzi~dS9QhL3oQh&EQ61u((Qj7Rq)t+%7vIvg+)ACSv+x>U9 z3C#QlBf((?;Xl|f$Ng!LcTWXUpO|6Yg%Ok@< zy}G}4@qO+ueie~d)2}eoWu{+6rM16kjE~f-@;n=2r#~l3T3(Be$QoLKjQKjs#?&?W z!lSFob`HVc4^!il!Cn$Tq0A7Wn3fYqWKi(%yT9Z(WqesPF*W=9x~y&e_^r$8sQvu( zrR~_X>$AF0dDFylz4oir>a&CTPz#>hB_$IMMsk|@%Q^P@Qry;xuq*$68*6RMirP)V zcUmp<-ZZ3U>O?*xg)bX+IchD^Sv|+smuK{SW+gpcDMfvZtfhI?>ufg+V{`q@1bq1R<-TcFOW99GOWHO9NFNtDipr9OqV%jf zeXs85Onr;nt$zp9ZFyJF-&OFkxcz$?^1z7f1N^$&Z&unsdwYI#^erk%#&FgPX1eZn zm!nsY#odzIGoy9e@qPm<-ra{&mMQbMr-{C7mWOo-mg0-x%TFl#B%8FgyyM5I@e6a8 zwkV(Vl=Y&q_`{c45ATDwg`@ZGpv4l?&s)FPZl9(@a)zX(qSMz;$!V{)ta=&hQD-an z4*1n0XZS!N^6KmKBbYv8O$d3_%GC1?GpRN8&lq@p4EgJ?>msyisV&cVAJ;3z9DT+H zC~q%B#7doX@4jCGKb-5G%D>lK)F@^Qrw3!yoD7%qD*8q;S3IeQuY0&1tICxw4DTTy znj(8;lOPKUzbY{6_=Pe;XuzZCTVFP+XJHfnP?Nqzj6b;ui$<)M?e+s#91xg2y+4F? z9ckU`I5X|tJHkahy16*)0U4&8;w~nURPt4a^kYZQvc&d5apu8Zh1Mo@7flBhg6Mu} zX)9v<&1kS(CsZyj%J=XFK_>#9;^pa_kt=W8y51bkG2R0j_MoCPhx8UziD(g57SU&$ z&ExSd$C|2hXH0r%4Y!H?g_3|pfjSKF6}NY#f4CZE+_jl?O5~cMeRCbcYD4&owq0ng zU$)EA5C7R2`o%#%v0JHwpl~wLa$PVfoCfdotNUCJfs??6`0GI%V$47mD_!}HakQE(gd-MLIkJ~wUG)k1#bMbFMZ}Mlipo-F92ranx%PC7W?D$dD1MA+x&QWoQVz{ryxE=f3g-$t@wl~D*R=L^Zm)>@j5CBwXso^ zXJ*jOmB$54^7z20w6b9F!z)HJrd~hm-3uV zHkCHuD@)92O?U4%UioS5Ix<^k{=6GFF6Nw@kzNMc=ziqpk9}RZmDt@t$_MzlXxnZ* z;=NlIW~E;;=xzyGkBm)H2iuzQ_wnH``5*){eUdkjVT2?rQn0! zt^(3I$T6Hs0mq6`u|W5+Plnd{Do>5ux>kKJi{aG_I&wfJvqWtX{$?hLZy)V zt;1%qm5lNGK!@KZyya_!=4K{77ZvS!V!bNHaeQ!_3J=&Y z4vj{T{9to9KWvHu*Ad)6gtb7yEjaxYWv_tq5n#web=01b4k@n?5uB-V>d)l))IP%f z%N96OxGV@_FDg2h7&JF#^I&cN!R|2{#SFYz$=%{lqFN)@Bd4Oh)BX$beG!5M_ECH>cgR}k z+6kqj#hlShP~th%&fA>4KeJv-I!p43mFZAr-WlI4T6bP&Ce%x=z#9jT*!F#bC6oJ( zkDv7EQhyyE26p*F@W#gJ2xSbEWK$c+n9Xs?(p^SFrr0G8w9`dDWf0h9Xwz)$8~L8j z-<4W}uYLCxzd<+_y@%ZA^qkf*oJz5`tcKF&_zER3Y5N z0<|P#>cnf52Rd!KDm&%iL{a#+eMRY`XL%sK(arJQ#nP5hz=j(SHl(v;&>~?ze2xPt zb+x3@bt%WJ0k(xT?Pdw$axZ>GI}D;&r`^Rr=d_ku>vSE{tJreo#H``RoDz<*W2U-h z9fZj1x zmmY4DnUz7EU|Y2s&;PvNT;V6jE+TC*#q0#TDalf)YtSl2qlCem@x!R!s~@Gnn9F_& z#O<#I)85Z=3orLC?c9^y+^H!Qg_H* z6B?e!$gTxZ|Jt_pCa}V15Bkj|2`^g%;#-dH|2PFN&nQ} z5JOGG3^=0Z?p&ZA&!qn6q_4p6e6g(dD>b+At(nyiO6aNNJMcPNb#0f;;n$+X&e;&W zeHH1xx!1)fWtpEch|D;RzNDC)cHZ<#A}SOT>r3I6G&k~p*^76t6htI^;4vSJBGbtX zXf{mpy_C7@c1sS{WzsZlzkcx-Asz3{Wk-{IKN}ABG}Dyx4orAd= zI37ZB!kIBy@2Qv2pk%E}b5q$xsgeOg{(cB^!(8W0x9357i;I3M zq8Foo_rrFpl!~%FrucH7RW_X*Ph_`-`Bw2Nh2)gI_Z`deRs`;m@@);cS-iM=>oHPh zG&PgQm|*|o(_v|O4 zxnH%<5RzkxYrD%Ei6~?K@^SiIV!mc2?D#3H_2@H^gU;v-zf(|2B=kJ{@|yn3B>aNC z_05WYcb2~=Wbe4{uoYp;mn4?4mDz-6-97>850bkdl)&s35zm5~fCjaQrbe)h`y&f& zZ`?7Xy5_f+CG^2=3Mkko8i&;3Q4-g8eqb?voMC27b{`TG74;|>NM zorw8`9N)zQe`83++{wM}ozW}OHxkF&V_Nwa7HNj_&$pWu_jD879;FkAR<%j|d%|TG z(a+-x^^#G=rjHvvhrvY|H8dM-z7P^S2%PN=MB> zjrqoGG~2Vjenzo@9jL&mMq5j+P`*Xs(E7IoWW|xT`^mP4BeftC;0F=r>L#%WEDw_{+cf&ACnW=Qy13U?# z&E6&YmC-*y@@XO*{YBEc@3DV>l#5QT8!P1@OaXiQ8HD%i!wxf6uXX)Q9ALEmI)FH6 z6|nFAgT+Sp^1A?)j=bqxB9u*IWg0aa6eB>W-$P88Y!I0YM;D)l{Do}Im?~4qDMTkc z<5z0Y3zN5EaALZ2(%8{{q=XsMyHwjQQTsBbk}l2E#IV&lDfWzzEi*HHIAm)oyMn9_ z^Pk(|wOQ=sX@1nzCI7h*ZNZnZ!NL#nv>y#!KBqisqW@Kds!bLu92&0>S?6bv^)2h2 z#8R4pj^;JiO^ngnr>>WPd~=6vZch_RA0__CuKWg)Gx7GedQKg^@9Uf_ulv_JaaH>- zY*q;2&%@uTffs#!Yt7a6QfM9?A`tDT7#YJ78(B`e@HxpW@wLRj4SN1%3h`?-LH;q|24o#AB(P&tDU@@DRJe)Rt zWJ1JiF_v!^9iS71^834B>-RDPbH21>tk&o3^@&Hy;O4D8ey;|6TkLnj zm6bfWbD7LyK$3f&TsFo0lwHo0GP~K8X0yGd@$il%c?tj}R8FQgDZ!_5aH`bD(KY;q zcgDQf&5_QeXx1=1`S}&%rv(E9&O{e25!NVk4!1%t94n$(n`7p|2I>rJb8fSV`D~AW zJ=3@o%a$G;rMmw^o24Ug`KN+!x0JOTuj`|-OP$VP_g7S%tU~KBu;9XLv@nCGawIT5 zllH4?5H)mI+If}#*OqQ`eE8}nz&rHKr8@w5^mNi)Ir$7E^+q&}aESBy zITAAPZQiUEOKADx5Lp0fF?e~0gVu;M4+^#Q%dfrfoAgu#O&|T>AZ`yO82W!qeRWh+ z-5ahV-H6g5pn%fdAV?U5DBTUx-60)Ah)9=&fRaNDokJ)o5(5lfgA9!fF~mKi-|ybL z&RX!#th3LqcfavG&n{4lU%28Bi?m+mI9c6zS^p}=L+BWsq$HZH_%-WnNR3K6>F+x?{=Z&X3)VwkfnTehZq%3cHCCgsGLco60&kht8m??LpVO~JnU_@ zkZ7cujeX%xWaqV9#H)VD*=WdxkC21`mpr;|jrw7A0PPSvrk_<6l{;sUF_D`f_gzNB5Jn6S#hf0cC z-mV{fggm-6hF{AZbUIuRO;r3v@8MBR*6<)vtxu2$H)wTqBXz3(x=F71m(B3x0BgrU zez)Qu2-K(O>CF!hrs%S;l^HV%ixjj53@zeZkr}hcO9LC_~l0i~YO`ysXv6UhZ z04=c)NjNsx79aTn0fqp_(#R=Wd7PE8V$*JqbyA0KOW;aGR9Ky_-{UD;S6^n_#w%3b=`w2v%ziod77(};sSehERPRku&r{i5-F_^yb`kQpBFt-;2=Fv` zc9X&TTiQ>LC|&%zA*wq`mX2pBin}f`dMp#fq!c?FVx}#A>Vwl=jrgj(^?zlZ+?ysEx`wJ4u#@5;+9oB?ILQ zrTcx_n&G7YFF)+lyK`>r#~C7WIQ5Y4Nt$h<{$~>+r6yV7>^Eka%`$Jtz8NPG?5MDx z#v2FfKl(m5Fh{{WWSRXk_tuUd{JI$y)t=vSKDn8Z&|U2oku|$6U}oW%-Fd_u9exOT zQp&B-B4Tq8Jk;utT@A06!Su(=Oxl5J=2)EU zQJ9>-pFmwbk;ah2#NS8Sx>W-YwH}WqDQQx@@@O`%_MNxvvHjiHu*)i;p{ANEaVqAO zk3AG&i8hjY!DfEaw&q}(|0B{NaJ?v>pxW23Gpu4|LR?RM-Ye5p6@Q$${N-Ezx3WCc zu})5YHu~Qg=SAdh>|%}Q{rLV9lztko|1v`BJX(x_E!q)J_0NVdxpr2VT)POd^VPu)wi!!YZ4+K81!?>( zINUI`J>U%L!eF}mIT%G>xD!&*@d(2y+Pd?Fo4k-?X6qTY<1lrrHfSny*K z;dJ)TYWy<4U*N08_r;&GGw{-l_!+4d*RlL518rFEf-q^9emunPU?jTr3)6EoQg0*{ z;sUUpOJ`1}2ysfcOd5Ny3q!fftBC*-RWsR_iEyREu%ld`1^Nhb+iV>HZ>6vmGj3Ix zZIvqLK;DaFo4oN!Q@cxR6m-A*3dlGKp$`QIrPTLkGCiVB?pY>S&4s;CpOAlI+p>YRk1yO-XitDX7Kiix`8^mr7`L1i3#R;U|w1wOF*O7ezJRK3t-$jZT? zmGFI6AgQPV$zx*D;H?ww^@Dp%8={y!a}Wi@D+=87K)_OQt1Wea-XEIgwDpNL+RYCl z#q{V+!;nA_2(omQ+YoL}ky0L^G+v70?5ArC`JK2OI5EZ?;#ROG(Ga%Mr)Jhun{<7- zUMdTFan`o$VVVF){!q_vH;;jfi&U+>mKd<| z{r2jucDOV(X)tJ2|tjv72Zo4^v_E++IP5o&S-`pkL}dzi@O_df6LZP~LG zxe9Dg&|M%$5+ItKSfC+Hn@O%>iJ)Hmz@Xf2FW~LJ$PpaCnPVo(05Xam>jdAe`xZ1W z!2U1~ojF@@qT?eqS}J|enNG_%gwPWcpwj!~$Y$K7M?C)m-{R|wUl}i-GJ^6oo*)Eg}}c1r7l%ta)5+&ex6#$YngJV zQ_)8qyaL+LZ?P%cRH;{v`J6xd#~os_DjiQc&--77Gq$DtqNbtJI=nuUX6V?f#DI46 zaR1>qKJ*O$z>^i*CDHD5^Jk?k?jI@Ndu_BJj42n<*K%cPYT`o4C~@d|t1D4#4!apj zMsq^Ma^<9s*XzBSV?%3W`u#K2uFLNOa&MKI{O5W#C?5okPGj62?Y+rKH^|a{8{*VP z(G)HQvpaCnL*XB%od5{n!ASia;6W|7OGz^GZL+%<&HKp z1y$o0uBXJtKD~`a@LhpFux*x;2qD18vblbUoafT8j8xm#`)|3o&4GhI3PT4+2|9F^tR9{M&QgvJ`7O7-`DTN_+?h?UF2x-$wA>%c z)NtuQn4yEb_Yn`qC+HOfe9KLlV>AL^QES-?I@q{jC7qJCw)~^pnSLAf?RY(r|&;k zPK6MBSw@>&8m}04o^W;I&-#>m8ysB>!#CaZmiA-fin~m=T@>S+?tNo*YPAbY;5fnEo`O z4=(4dNJ~G>9<)IVK|n3T+NWmoh+5^mmvmXoh}`JDM(Nr1>RFQg+r)QkoDw(y^J@W|5r6Zmdj0!6g;od)M=N zy@u1p_SB|n@s8HYV>@X>B0il@#*ZuIbYeeloQpT>9G0FJ+;~^dVIPdwnS3xZZP+N5 zDKFb=6s`Vx*MGp;MS`N~+fug0WW!W-o7JVXjISxDEPBO?kt=jgKBuHB0Q zYFY$+_jGp?&#^U|0vT(8w!&@u_+p}WIz8xyh>K!2Prz&}i`v9v<}gTxYkHG?5Z#i& z>B77g2j+Kh;*EHIuuSNoBOACh{Ou^NJf(Hc^fe{oL#vR|ql|{h&bymH1<&zZo3@~H zYi8p87dAUeE9zKpSpb(|yPqDL(w8%d6%**pmd<3pX|#suQMt<;uQcI`&`n_tF+Vr& zoX<;I8Z2iup$uz^9cQmEscJX40cRu9uP)9dd|Coov`Zj?Or~k#N$^i{;39zm)LI2Pd`k zowgV7h>oS^^V2lRf{#l529qrOZXQ#G%jF?w=%i2Klf9ahdew#xvQ8z|j%Nfww*(-d z1G}(u&8j}dzJ2uKK56;pot}pW$n*%DMFozORY|<5n-03dD1g@s{#Y{KDk`KO3Ka~QrRpS zJq}t5SZJzbzTaX2vp98ZY)OkYIBmfH*@^nNU!^!-S!piL>n)p))iVewh?H>5-nI1V zK<=UG#%t@)(@sY-14eY4=D%^85_bI{p7_S#7X8Gzc@MA%1GiQld1>luRkhA)Vd>rP zydBbh!^=aBI`4?mUZ@|`e!>Ztt7OA>qB&hLy&C6F8aEAgyDyW>>(lol!>8!B9(UVn0Me(@N)`*DX{O5a;Ld&!CNu{~|R zJe+NT3;jCrtEI-B-o%r;o<1M{wFY4bvd#HElvfIIFm2iq=`*>8GR@FYncGU05JSs^ z>6`nTt!`r*%cdY1|Lsos2?LR%MCoy^%1wYT@-WNy>VL63?iyezv5~Eu;y4wWrlj zyrU6#;jxmkMy4|7&4}uJ;+P?M)J#Y9!eKQBDC@^or$rJ>_$&mucrn#StFe3aoexAE zFL_=PK$-&0VD7VLk=@!$^J9um)J7(AN^c1>-uvI^U9L-JfAIeVX%t>R!4Q8 z8NA9F>ut9+Vdl^!JSS*tVtDUPhiKh8vQ*2{Rv(>8TS7hG$TRJ5O%K9NoW6TFZ(y=-3bxF#9v<|Jr-jH%Q^za-ZqOvdD_8oKp&G&-R*1o>sh*z4G9W{7{mbQf#qt+27ewors-ntW>#$!)kTKS<@}Gba#SUoHY1IOrhDV9s&%$ z`M;`}Z%JZTz$Q7;^s25p8s6!v@`$vS+yQMcsyAW;P>EK{q_@3b!oR3?u6y}(lM{`f z$5wnMRx6kys@ce?bw?ao6S8C#W9x$w`_@~+!CtBtD?5)MrQ&F5{>2`7l9=**c z%0K&qDDA0G625rx)zK8h?=&Q4_=U6Sx;c&?kgssF`o=QP8|`>Lyhq_;J#=Bwy|x(( zX=}$HqO^Rp6Q|bY=n9Gxq44IP`8IJ^b;oN|AiKwXFp8^+w9kpTsxW4VZT$m=!|)z+ z;DxbSWh0@JTXkZ8fJ&`7Bd1hc^$>ZClf}1E>GN-{1F#Mz=kv2lA&NR>`&{!?q)_MX zh4J9xivSSwlbQG`yBJ(uty$ z<3;wF|GX#el$9xf+!QHDR{9?7#XY)sXlt5Z%vr|qaSj~#CEbLf>LP7G*H6>w?qlF> z7W%GxdTYxu79eUZzg&*{=i5WucOMUJ5U|X}$l$L!#D4_NDOM|U*d|7&f!reV?+P-T z?Cyg0nq+(jnD1yO3vLb88t#ixg4SUzLIg(&k1=|u6a)3PNF~mFAmzVF)Z_MjBh)(C zdl5x`1fZCUcidjjVY#J&&*>qJcyB3C2S2fAS0-h}TIWA-Vpty9Kr0qFT}^A8v*O*l z8S4D+zt39ddn`&~UYY{V-0WUCb|K!lKag!Md!I$hrM8OvS7G^$4Bs1+`19bqwwVCK z-s(Ea-99OM%bq!J|8Lphp9V?ZQ{g8Ox-@}!c<3p^i;VIobkR20yoMhz0AWQb(E`}@ z(m7&csffdvGVQiTOJAWQeqE2_%eIkJU7N&-brXg46$bEGhA}Dq{EipsL*xiXdZ7Xf zGbzLovy!Eb8FWGXq&!aY3{M*JnytD8-!cKA2RVnD-=X!&L6mNk^~bCaT#w44hFkoS z!OdH6Yy7XqgCFd;)ahg5Bk>(X8`Tc_6nHxP-T;&tOe!H|gT3;ef|i*epGm)=4}Q(9 z6vCWd!*eAPwT&r&O2RVtBDM;L=lG&()n;~up{huNDKy=3YVp zmT7j1r=(mo@1L93s80X*{K*ZogeF$*;XbB-3^S~~rN6i513y%VkyN|QYAVo79rlPA zTkRGOJtjwq$xZyhUaURFHtj03961O#w}U$wGb8e&mpHtJ-Eg#N8-RX^##)TJ+ZR?( z`Zx>n0Uj`SzZt(LrSB9(5KqYt%Mke9as>+?swHZzk=+2C|eWl~@ z$>&=b5t#o-Y3F6c#E}XedWnfL-yUS&Renp+3?eK$F0`?(!EkAg568hytPW4c9PMS& zQ>B)^UB5)0REYCrEI4g){kp#cP)s3>1a&n(ob=`Kfd1%2Vy5vu&a1qpA!3=%q@Zj~ zy?Y>^fvO(PeOTYmox38KMG@mYIPC6hdh;Vy6Y>dcuc;F=@t!P@h7>QMt(F^L&;nog z6w~YF3EYF9oWpn1ar-OCV zyVj90#JGK)3dS>QD0&4R_EQUbOp$eq5;GkU`Cn7t5@d{1Pf&eIVbR+7^@i%qIB>j4s5Pkj84IGAq~}yq@}dGj#t6K`54-k=cDOdI}l95 zO2C37lp+HupArBVUcLK&7i=(;6iPOP0-L`UI_!Yq_rQB9H5 zXQK5xOH{WF>lr#7|Iq1FNCgC-wI4*x&I_3CtO!5H5|spj33-8)@Rz-%2e1eDr3X8x zU+B8d<4;@Dr3va_h#!3`^*E7+NnVkLyn-7CK6^$^$@QS0BB^2I$e4|!2D0TJt^E1n zroid@zisKVh~%7YT3j5p%oZ5KH+tA2oQi9n`I{v1O-V0iky-@54E@D}?yNt)&Z1)B zmaz~Ko6W#BaHFe{peK&Uw}j6>o#a-odh8SmG_U=aP#dZloudt?MF5Dzr*z@gztUO! zCb#rxAwXXh)n1A_WuFlZ45J;kmF5NiaO7-jmy45h^)nH#_8O0iR>NFeTr?d26zpgN zjdT60(e*RDx%;$BG4IJJoQ`8^dz#Th!ETA7%$U`gy7JC`HNFAx)9hOa(1w>WtLfXv zWSay{3XYN|>&9OGSm@dl_MT}!>!POtIRrr2QX!`5uURYX${N-7^7VbKwq@dU1cx<0lg?l~~fV1iGKX@^p&wxXxIk9)RR0l@)VUoAR~+967054$cC#^n6B zg|XOvQS}zQHg6eD!3QLoko9ci7;;YxK*M>W-Q9*iV=>yI#O4|)K4fM^hpk7SX~W8` z44z7^i!1~b2NWtIFyu6xn3|ldTgf#x#M57idxT3dL|YcoaQD#*-e!xpnl-_UH>jZ@ z3zI!V7InH}mZwi6jxeM+@pnLVp_lOeA>Mc@Ne{qQv?NR3zF@x*moRzTnI2n+VSG6X za(_DFP!TB2nctT!v_7FWst}SU9NBRqil<2+Ero~`+F(ZJpJM4UL2Ka(o`5$$g#}rO z3o?nG1%5)(sZmt({rc+?h<`sY?hR&i-W6X9x<@#|P5XHz%;AHlLkc<84+gVj2G6bh zei`RYzWTqWk~m%x6eB`T0$PHaJKf{W7Nzja?BCSvT~NYkNwJKwe%+daEE%KL@B!_g z$lrW5GYnZ07Ai&V?Cxnen7J)<`HM2=-+s_RcNrx+dSrk~lk?VgIZN_jQepwxAx{=O z&V}=xz*R;s?%`NRA4Q12Qk0UC-SF}cs|CdBc06W_ftGY)F(wQ z8(qUe{+U(jH)^85{Ql=&bQyZ*(*sHts(aQH^+>Z@yav%ZuPe4Nw%o$)O^<_pzQw3) zvozRb-Z+($tM>&~Hhs!Yzm`RxXFXjh*A^E>(FA|xZwWTToSK7#Mva3!2G^94(cwW* zw)nrab=B{ncVx(#Dc;ut1WdQ#ABCQk=^WSh>AV z{t|#5C{QUII~`1=_|tw}c~f3ynC?~nRJj^xj3SdG(cNmNP@z02YnOG7mp-<0^&vm~ z<+534GH*O^twuMdnx}{J&-}p?HR2-@ddkOitzGu0SH#Z^-}c*#-cs@#xUcTkCPxMW z3gIjf8Ih51`AUvtP0Dh`X!U51Qld^m3*Gx(XeMcKad=1!Zvs^_i`o~M{qk2f`F&En z(#x=nj?al75+o`VL_D_-4jyp#2p%^=X!E(bnek*7^EJlt|8_jT#muB74C3y}klwtV zQ!8FgUXJw}N(@SDP*PWW|6OnR@cmQF;Z%r0?2O-eu4~rYcu+)}Oa(7A_jP}a-BeXB zp3cGsctrpY_UmPBa8DI$J)&S^*9lGFHV4q0b6_m$#fGh20-UR^l1tE9=VC;EBBn60sDSA5hP#ZP}O=@M353%wBJ ztS%RCePGL-^cpJmi!x(mu;~BM!O2_~wPR{+unmMOl)lrETRUOhzLfpykJA3{U0a`c zka`ulrx$gO=j}VXr58hd(hn}c(zJiZiEcryjlp2n=$~-K(y4Mp$|$L=Bvyv=JIi@GD_?_ne#^_9<*f*APJ zQArcu4dFXZs}-fh$84t5u{FjK?k~{z@?DE`a}m#k!TT2aHHjyyCQ zgY?M!N1j|*xOC=a7i1JauK9yaMN6+VW055`ZFZZw`7ifa7GuMb%q(uvc>5B$xFBwK z<}T4q?@hC{2IP(=Q?jS?=oPm4ZPeaICd@*dj0aIz7-Ix}TzmxePll*EI%MN>`!EHf z1=ABuD&u7N1-ymhH1=F)aw>+>)M6rcM~bFqY+xXOxI`ZGSFA16)h>#OK2*Ah3qtZZ zdS_4X!!E1jCQ2V-HmjmuspQe~G6Gm!;NSI1-Bg)N!9m}KC;d%r_RiJ{JNGOyrE6Q| zj2G!L+GppTy-$UWGhXrFT6q9=S zhXz#^Jr(oU<DohCk;&6Z=TJW17|P#3l&L+Xx;ky z99C;>XrJ%*l>~5vfGfk2GcUmzlSB$gK&f`v?J-*Wuf)iIA!rJYStiH1Me=>Lo=teJ zxj^oFGapAi6W@b^8sdvlbez+G#F%E1b_XW8oE@W~`60WD>x8-%^nEZHGH~De(Nq-1^uZ2}s=;v5j_G*kFn)_?>GghJr+RZM(E71jSl=Tz8 zY2n@heS9?b`x+eT=POP)V6CmZ={D*yX(`gC3Y@p>t2i@S*E2?gVSZsw_(QpqNKQY; z$gQwgaE_FBfZ*IP`;Ugtcx_E8#oXZ(jzjp|7^BvL;bAZxYG#w0n4fiX)^Ai+R=(KA z3IMr4D+a%=S@f^N_FHUQT=Gw3627|v87c7BV(bY(CQvI5tCJ_fGf|%^5S_F2DnGr zanM99FY^apjRuqqFs6TsnTqTKqZ`LM2MRYG{Xo7L{}2Dx$-d?I5HF_T_YTxXmAsOjoCMkHqO55 zcN)iNdn`rb(OXo!t3g19)0kA*CmB@{@0J1SSNctl!P|MFZ(D4-n(foS~F5^mf{-aK7%(d{|uJLvJ;45#uP*%09F%8;}-|dCE(FWl0(+s)p zx06NS@k%rBw(fj9*m}jg{V;fQ@zUqMy2k+eWFVGbZ(UCbLm?)o;On|L;f9R9GJYK~ zds%1wDszBEH;UB_hbCcHCc6m~sRzZPigUl`4T-kQZZ1xKClJD|oY!sATWm5zhLXTY zsr|bNR0fi-X&qqachJb&1+W|N~3Eyda)J2TYC?me_{Ntww;>w2b8qt5~9k? z{dVVmX|{^@DHNI{eHUXnUZ~H+DD}-@o0xmjK>7>Ioj{^qWf6KY<)UKY3DR|CGvVRk z7rrlbyMQ}7Fk-I%yiokbj%N`{#oR840!_#}TU)7}y*)J?14>2wAML)0`i)vaM#i%S#=~%e|7W~m?|Fh9tp@Ift zI2`(Jj*rrc=kx8`gu&OifFR)~TwIH@iKkKvF$6)}#{>r>E~|(1J=_|5 z)|y~}kD_!bC>=TFv9u|EZmCh$I}c_Om8k>*$XCBtu@W~{NGrlfS1;yi`d;5uy&;d_ z7m9Lgk&W0}WAqRT1uhF|zK5UUB&3hGnE>XV7uopu`^)r%M9Oa(UnyoNI?^)}lt^)M z8V=`Z&UwE3vrNS>+s9Y-jLvqBBO(*|qon@1$xe>wZY?jkT@&w{SlHkYTk*uQL;i1} zKvnyoZ-Jd5dza2j{oBLWpn21in@x$So8Njq3EMX*8wyt;5JCAXIjiltX`7C+gq<5} z5!0|;oh&CuO7Daa{mrVCnlk0cOcFA3bmxFBcy2A>zEPOYG{b+54rouA%*gu zz{_RVPEkRp{Bd)Mkspz3$QLAoQFVmjEs{v9Ag{b|&9x@+I;Z)8_|2COWh;;|4g&o3 z@!+*aNI&bJrYb3^aPXOQ7R~gU$7}2eW9E2Uc!+dKgQQH+_mJ= zw?Na*hnE`i>op+9(s z=&#e1gP9xfsZQU&afvyWobg!*wqBFazQ#mC<)1Gc>i*pxApQ6D08LTi4S(8q!~kfW z%aS|{0JH|O4QM|qV`5Uz5%Z88{NZB^p*^r!&jzk^;+2ftn4D=m;fI)8S(=KQ<*TpTku+-z#+CxuI|fE$xSx6be1G`E3BV0 zU%i0J+t?Bztj0gPzb-wkwVPxfsHq!KxUJ&3oJDNpAta-;tLhq1=RwFpj10VjzYp(U zy}k67ccR9YI8hLmvcG$My<#=9T;?`*DOQ!fPa-D()fN>-tdDklfOX~H`*_^+)pB^F?OWgd7q_oHx zE;)!jl8Ed&$rR{p^bwaa-F+HgqRu(E+H&q6V7nmoDL6Cf#B2RI@jS=8oFk|XFDWeP z{X1o05k@;_m-M z`ANwwqHel%l|=sXf_B{KEj=I^4+P_X)pdLN8CAM0^oo7R)gm@5DJY0osMMo2K8mn; z&iioSdOCftEijQvK6x#eSo{9ip#~ZySyyL$_B@&Ma#~rMfpjsQkyCV1g0;Vcg6DQA z+-!hIog>0VamJOtRtQJsV{~uNJM-vYQh`Qt*C?m=E*=>b>f+Q=LJ2EDO5;r`u5HcS z;tunP9qO)wGX^)XFJW3moMIB%5k=jVmSxYL+t#J|`>*UX zP@Xf#D`lD-e|Wlf2}48SarXpDh_}^qu2t5>8Le45;wb0So@W@F;oCu2M0w^$Jcz>?@lWf&eB;*E!~`Mh3g$sJS{)@kFwiM=)A z@U^G~^t-Mr@v&5Vlg*t`!K&`P+uMBubliEpvY|izG+j4Lj9E{@E$G2)Nx`wtMWm%J z8IFfO`f4CW2VCZv|GbDwYD9s9p$b^u%n1rbL090WXfOJ&|5=2A7L08;UASqFaooj4 zQ{Tu5qo3|nyYWW(%Hr40v!k585}th_1C?|4_d_=3`@?B(>i|`%|EVZWjuSPkMV9HQ zZua5wLB90P`8s+Quku%1F!9Mq(R-Bv83SD$$5T zgguiUJrrhhPuf#ZC@mmJ#`J_#hXAdA>t)IX7dR>=yfN&0~t4eR`(Qy1l+*?5_H`Z zw{d%Q<%Y_;R~>l1GpQE(klxW4?LN0DN_m3-MOqTFP)XD~w$s+LFZTTSskxF;XT9W`N_YyCOal=OC8GmR={TK%$BnyV; z-f@Nol@EQOzP?@fc=XIZ;@ida+W|D{+UrTBv|L%V8(63 zHXMhEivyC&Yye_O8$lix#$9w?$eD&?{*G1x)^~sZ_luJdw6cE8gH7r?efpOesL8+VCsxr^>67P5{i|Zrhu64;CNpND&+0@2 zSq4RO%U-CJz-PJ?&!sgLju{%{M@TuNVrdukmyyrv!+oB=7KIc!GZb`<-phH^!7k1Z zVDpb8eqo_uQ9&NQMk|Ht%Q;0%B7OBtGaPviPu1;aU87#b*&il~<%=!W&FRNJ`!m?z zFsaEE{!XTe$6mAGJpLSFi}!=*R$%8_3%vX!wvioLGZiXL8-{o5@X)bnbkc<%1-7qB zm3M`G=?IAVx~1)BPhyeJ1AX#U1l7SsVOdeeS#XVOH{!sn3p{UXaozf}iHuzHEc0Mg z1jPLG%JeEfU*tdYdgJx`hJ63+X@|2A{-9D(e8%8ss)%HIDpvkE>g4Hp(VYI!>IpuK zOli=+`eb*UbrDlsWI_DMCtMnDz}(giP+MU~cfBLzriKH_InNN9!UngXr{lcirBR*lFw5mwZ@@iJ`UZ7o~)zn7NNeuc}=5@I4GruWG3#*w0-0{ zMsH<=y&dMn&T|%j^wx!Whrdp4LA9tWEgQv`k&#K+*yK*R(|uV_T6^)KrZNv~e6^Gh zz=-~PZmcfk6!kmyM*?1}?n^}2;tyWNE95v4HhHzLo?G@dRX)4Nvp;}U2?S&R1j`9T zx6Gc_-5O%$lDcK{kPa0P5)#s)AzZd5>it}TCzQ6%JI{BnDKVEOksMr`1;s{wXZ{bw$C7kCpmG;%klHayRJXn0yp$y;$5(?|? zl_W-Wjy16_Rp|s*$k_yK=JY}Vb)cWl4DT#XbE~uAb-`8N=!CcLy>)OD%M*#YwR14# z$B49}Hq!9)C9~b$&9`kefhX;!cB<}xEZvAr$@UE!Hyn$N83Y4oT(^Qk_#Y4-)`bQq z*bbZy`Omlu>~%Z9pZi^hoVH@)PzPSTqj+vo=omaw^k;8 z*f=^jDReqxvNR1=KlcTeV<8!)+C=I26MQ!4vLeCkXm32xO|X7%ijpUTM;~$06T#-C zMUnGVG%DyGwo3Uenu(a;_s?Ray~BSG54h<)%1O5Q^xYg9DmVZta;%YKRrcMQv+Esy zRCkhiFEqh_U;IGiLeQd>b=6Lb_U5o{WHzUHQPl&@-)mR4Wz-i0$`nPZGeDnt2&j@# z2z^IJ<3IH7aj?Zwcg2@ojVXfGFpfnm;qAUrIsYVt_=a;WslqgPLLm~RWDEBaM$(t8 z#=3CT-)65^t9|Vf+}w3lSar8G967=86619K@99juVm_@IoshO`@dEiru6* zGx4;qV@5@`*DVQu4!WYEck7{IKS}bl$N1OiDP3j`V{`z*;dVGauHxs^W~Z=GnyRwn5YNM%!%cDhOtM2G zF6;X3kXy)0QPsoS(>u;JC{VouROc`t`Zc%v?6S}H?pFyLINKDTWj;ZboN&Hr)tyG3 zlLi#jp?HCk>4)_R7A zupEY-{uXeQdo_}}Z3(^^(830)G}kxOCSzj+Fx~&p-zi?Kfhk+RzH~okhLADX86=#;${V9>=lUBCB{FR7FU=-!27x>#^#gqF3oa|V+8b_<(YI_ zVuIfy-*ol6bvD6oi3fJdm1olSW$CFYQi;+0?%Asb=Ih7TcF#tYsM)Hs>U`GnBKf5x zVmoyhnDarBK8Ay{Ye;LAd`7Ddoq9G%x^;#!umX_&b$7N;p(1rOU9d!NivlQ&$m@7q zQ~DjKx-$v2>V0f|e04JRKu$wk^d!gK&!oQlFtB|--yPO9c6)kdaYb*5PgA9fv;sJ2 zF^^n69vTxeR=3=+k~81x}x4FpC@#j4T(t5 zaoAkJJIOpZc!f~>8kp&s_$=6boYq=T#_^4ivx98N=#OU-cj9Ku@vyqU%K=@`vu*UR zobPNK8ApCsDZmRf&LN4#4OinIRxisI<{JOKF)N@l-{cL_N646wTF1*$9?N-zO*7`2z}2w57x zPG4ohlugRKgP0HBmGP{lePY16Z^^trw4YzzJ8R}PZ`|c;qYz`eJ4Qkbo>Vk#6Wh6I zb1#Sz0+z@2Bje^hx=#K$W!cM7(GR+dZVxwxQY)VM2j(^}2nbI@JQP3TJ(GMpMlRJ^ zCbZY=Lgvi1Gv>8!ON{i3;a$S}&%^i|z5$PMWxe{UTIW!0g9u)b1^8DmbiY>%EwpmqW}xXDVApPa;xLBCqk4#nWgRMDQS(pH~$6wJ@DC{L;|exXb%N9s!H zLzVB{%^}M1sP*< zf|VyiR%i#;>bjwtZe$hs~{|O0;_Uc|m1&g|{%Mx4l1UY6o+ zAf~7@BN#=SHt{`q{7f275>2~t%-wNz5VRRUU|Y6W{aZM|Ha-8b6m=okwe#${aL=f7 zuE0UEaNdOco;{vWFx+ca1gc+|pV}n5VYRYxOA0KWw9j@T^Q08L+~j+GyWw4%?#}X> z$?=tPGXsxp?*-L_ZT+H;_`CGYSNFZ=UGtmA3K%QZr3IL!LQnkS`UNg?W}2JFrjXfJ zOOQSSUH7Zb=YMWk8e9tg?;%=!-TCX9taXa0fMIuN?A~D5Q^q01&(Fr~q z=CNeuU!RNfvqgHW=)svtW@s8Lh5%mk)K0g+YWJ@v8OLH-lRV0@2ZsWyYc-nA$60Ht zBb$n6fo3{X3`~<0qi+Nfg1_3!8dp_|sBTlT4~Vd_ybgXSd5_`#ojbvRM``hW0J#QP zFYvRI6EAm5+c%%Wo$nZ@rG3+@n*pImJzJ=7lGw%_C7R85bza+BKU7fVhoYTr52h$ge)8k7g9@a=@`rw8OIZ<`E!Bp{bIin>f_n;95+w*Lp%Br+9zYYK#u;8)&i@ z(w2)h3yhYumqtdN+_bm1ziU5O?cWnZg;Ne8&jSCqtTF$CPH!%*U2pj4#|lH5!yLkz z;ypey)i|+K9f87{&=s7ctgdiL%p7Q7oIxQ5#_nhBH6!~&7Tll)0rxL?x59D_!;iX3BS3z1e4C!@EFNZtB+~T|YPdyE9iDPDaW1v+eOZ7aA!B!^5$m^Jx;DG%zl0 zNOgWSnTj=ywC5=O!MskO%m`=N?DKafc!(Gdr!|L{si9YAe#?^QYiElGA521I0)s=0 zJRzi|u=u6-aIArIba&x<7cN;b)P|(T>?J^-Srcm{F^zT^n7mp={E(ZtHfY+dA#3z_ zGUl0I4pmf^Pt!zyk@lEIguLK_^mU5Dc7KK_^3cW0;% zBz$vyM9=Q5QNzOhT_sQ?DqT_yMtF{5mdv=~D1;}`Ba?CD?1@oT!Dd?Bs<;gQ3a-VtU zyzcM``>7tt=f=0AmnV|BZ<|cc;aX9NK=0>k+zcBOQ_RuBn@3)gqM(O*%0V8ZpXC63 zohSI$v7q6piIjO@yq^^#C zYO_Bi6~*N|x7J_l_dYt+$)LA^f0{wV8+021z{5fR38qptM;>^GaK+jAex8=T_^lH0 zF3@D}eMx%GTPD-i(RAE^_A~drliA$d6t0%gEK5H&oM&vEt)u&S$p1azeX&Oei~A;K z$`cq@gOkb~-tB^0elIl0tAo4_J*eK`Yk$lMnj@{SsPnKjJuO0@`Gd&MkdTW!b zu7t-h=*ao&p;gH-=W! z=Iz@Uf3sOTd!z2`dv%lO;9|-ajOB5G%`=pN~U)j1>Xw>zPV4(G#!U1?c4Mdm_M z^aAr(QBQy==?|ke2VWlP=>7DlM&QEsPyOVy1nRb~$ik18H?3rE#x8bYC75UBQ<9%B zEh-k?jvp!OGQM~Z{n#v^dxu}LcId5?(`m6lIRn@l(1AI9gaYHObRdSZ>#k=QASleB zO6nEC*JWwr8G8k=A^-i5L1e8{;ii?t!KnA8a^e*p?mv(McFKsN+>oEj?KfnJH}@u? z7F}>!Tz@5~<7b5y-%{x~rHEXhp)h`hsJp0rw1S~y`P06cw(_3!_X~6CmY`R}!6A78 zLpbP#uk{i8XU`{cw%#Dkq4zKR}* zH=XqkA=Z{pDFxW-zXpF^&M%s4D-9MXoYH{4v+B5p>3^=|UJ9zY7_|=!*WeN3v`Y7_ z8zqDb&kR`;ga!uwGtWltN%ImHwPgXDu=e@!hdplxD|Od036Ph)w-cn5h_w@& z7eYd5o7U~?KX&irD%Y`NZ2JX_0Hm$ZB;F(NDAW^`+cUg#@NU9h=rilr;|mMUQkYM} zC|Q>pX)9?cLc4LvC!TFTHEL=NBX_d$mhp#~*Ny04J3?S}nf?qTSbpbY`DKO=J3~jF|u2M~!lgX+o7)@yZVI znqIB1wSqQIWyUbumOE;@ey_dpT_3IVq8O<6nLpxpm{pmaJAK>Ul|T61cp1KDkBiqe zU#HtyS-4NW$J6Yund_q$HY?t<_iYVN4S=)T<3#xhqYX_6bsh~LZRY45wBu7#1 zF$>pYjmAX0dyexhsl(i_to48UMm=syYPY9B4bTa|S~s!tb?mZatLJ$sNd&j<^g-Yk z(Wtta%kRB9oOR<%Ne$cgy_yMedOFE{l?k1S6zs;5kq5VILE1WY*$FJz*jfE-6s@jlh)vu8%LX5 zwtG$83<=1G`ZmPH{X}|L8)h@1OoD}Ntc(fR<$EUcxU0Q&eErL!b`-p~(^3&uUvecV zP{*X}14*ms^Yr@=hf9f~1T8KbW7?k0d8sN)z? zO#PlZMs)8?5q=tdTI&3;!pC(rYf^GM42Z&gha31`)=6s>R2=&!VE~M2>{R#_oFjK| zfjg4RpiFkFFI};a7G2W@OQcn*Q+G-lnC-n`D`SE zScG@1#J`_dJu9cJez01pib0Gv1ZU4!h?FA4cQ#|tP|*|a+A_|;F5eatke5n z(1*+p3MVFN4l>18@PPH7hbR{mB@-EPW<6B~x=)C!VkYr)6(uTLBNdn@o^gs(Qg+t* zXw}!E(G8H&O$LexofW}G{hJa+5B>-@hOqj3T9j_E(#_^eSjBbO_u~#`CCah@JB7bH z?1kh&`GOdQL?Ln;wb{3pZ3NyX_i69dnDT)0Afzp#>6(q2kR^;~X;IcI1I$6B>_OhD#xN9wIE}5mu zer_j+hBn6APUGCJR$1gU&8n}>EZdVo2sY$xK%OL$PPU1^ndM{({S&*}ll|NYm6;-> zGT%5>I*4z-eI)L)3}+II5OVFZdu0N@7tiDDN`D(}j9^i+4t2EhUCE#LzH#Q(gU@LA zn$6~`r3dy;&Y(A~PQQ&YzBkero7J`oLo%_x=nzGXb-I^D_G%5$;7s>hyGC`lxGp(!R)+JkvB43g*L3y}EOW+3G zT<*O|H~#`xmKYKI-FwgOruP15SS8xf|$ zg$*XG(&TJhQ{{LtvRXpMy3N?f7BVRg?`Z~U!#R6A)KxO8Ul{8kSPnDv30a+M>Htqg z#uGhdWXcwcM6;^u8w1djJZBR5V7WC_puSPuVJgEc;KTSyX%wp;o{x9L>w~Y0t?n88 zext7D^0S3Nvy{lp?n~v)BAS(d!!z+&)jxuJKfXbA9?jx`=#day>~_MY7fbkm+a^eR4y+7YI~LA1`mP;fVISN2U4K3I zy{pZPz)>~VpS(BgSRngbY|c2fJx!2!6qeH-o&0NUjo^V_;6nbdcx8Y7Px;Lzma8Uq zs1P`)UB+!E2EL!;WV*?Acodfzk^8u>_zfO>T_5?SLdMx87EoLHD3BMO+XhZ^MWR1f zobix-a59Ve@@xO&6{{<=JoGofU#n(o34;WWv8U=an7C83bJCHT@e%n-{^M<~7NiCn zZ=1d~x383+vQatsquRRWzA*lty7C)TkQ)0{<`?S( z9Kh{L2ru^3r`N5#Gowht))TVu9VDkjW*RSBg+!W)Q)d-gSkej^w31=5u`-=ZAR^NX6+n6z`sLtj_dIp~SxNNdi=fQ|ZTv89!| zHv2F=8h;wx&Nz_dAs3cy@>x_TZ%JT~n;b$4}boCQ31X4);O zj#;zv+Jh%{i~J&-dmy=^yLxsSI}o!^e2#=rbXfAd-`9vocME!#k0oJ`RWC@~iGTM@ z7hy?*aJTq<^@X@Tj$oy8752VPjNIQzmSe7gQ@{Wf6`Y&Iz2{UBU1n(E)jf4+ zb6m2DGe)`A&>{IZX{Gt)jJzyTGE_!ur|gYMT#^G!>*(6;-``ki+ka!zlR=R_3e3&|uW#|F#o#u%>F77-(rZ z`bzFJ{XU+H+f_u2i)8X@G)JG@Wx0C0d}zA@SA3j&KW38jE7^1nx&Mkxn>hyPg{bqt zHQ^g;%wor2IiDe&NJG4$`DyB>c*%gAPV&m~XRyi6b#5zy1XUxwk8%QL5R)v56R=1w znsPImhS0yR6JL0hZ7VKbSHD(@j%V=|NGQE#)=u202>XJ5-1=UAVxJNGvcpEB(C_+| zOW}4FohEaybIJS;<;9Zb3R7d_HoGYLmHZRT39fpFm^C^eCGv^E&>zsOZ0tvLl=Cj;s4PZdnH`+8%I3%@lq;H?hlDtas67+UH?ua=;6+ z35vE#Wrwg#OR-N3Z^m!D8>EA>wBW$q`}2=vr4SX(CMZigsy2D~K3qf&KWz+ZLqyHQ z%qr4W@(9W~>o3Nefdr}!_w%oHjjgIV)j4HoF8N{@51$E)TcHXqQ$!AH+x4xd{azgS z>>Rkov^}MOSTL@l1M-P7_LzWbsE8_AFN1TtRQ<1nfT*4OXs;TE@o9zs%pK8G$p?ym z-hAE=XxurS*mnsSQ@r-mpyM?W`?9-M%%6VN=>RrhxgI3Cx%%p(+@T zO~)g|$dD{NVGdFKnR8||YZa3X--M@PhZX4eGzdSONoB_KV&;zYCjr7*1=R4nkrZ-D)|Ll@3XUw5+6hFD1o6ycy717-8p5t+@Vu?(W~Mc; z<;P=SB|QbAm@3rcZ)v}jDVZ>*pNOoT4Q=*(*W%CA|1<=rcDMW`(f)&acnpAU&~Dlc zPHWo)weB~JTxfSPEj6dUX%}2ieOtJN*clfe{pz`C1lFLR4al4aMrq+Kct~(fjigEO z%6K2caI@>9EPL@*Um|ha=;9LJTNuQOCcZ6^Qkg}7;VGVM)1qX`5~7Ljk|VH|{z*Z_ zcj=%aZl}i>XTvYd+Y`#%F+`4ocqV#JecCX^4AJHK(`+f7QLPSylHkmm!yrf6ip>0% zK_HpLXKB1u)D0@7Ec+1!v4A-%IwG}J&)~e4@Z!%ebQ!6iQgFAcmwym--;g$4aky+V z^}FA)Xmc%>Qd^ag>D5M-?-O;K+iHPGB#`+5@YAdl*Z&NUD0U(*7V}C|QtP=7aK*Em zjKUm_n~PsW0wxNdX@6oM-v<8>jQF^Kd&hp@Q|IzY4uzSRXSH{3V z-OunSQh2sfxu58Rw*E~RoH-jdK=9zk0@n;c@-R zQobs?1egA<9|_Bk0v=EHv0JTz6a){e-xZ=SRk_AT@VVnwG^pvq`0c|J6JZ?*Q1OY+ zz2iQM$4HNu7J-GS+Xq@(rD>^UqvAV3PJCNWj@AZ7rHXMBr+gre|KHuxUIw0kE2=`^ z=LZ&?8XiAg)UK2xp_)q&Lem#^%MHD53w_^f%DZt@CC+v1N4A@K@}^DKj5A_3aVacf zG@xF{&^<+lv2q_2ZWW$UlNvjcbv|f!wE11LP2Jt@Fic)Ghv zY8;Kq2{N!?xn2r6FR?+nrY2)Abf-@N0)U++rYtYAHRXgJgvBwaFN*l^lPbn+*znHc zZ6}Ei3f1i4&ZT{o%o+5GcPz=$1c8x`B{6%#oQi7+bI>n9(&WzpF{$exD zsR=Ucw;X<2_E$aAQ})+Vn|xfZoJnk&Nk4dRg=S6BCI5mE$|IGS+R1e+N6-?*pXRrI zweqy_mq~4^ZB)r9^zN$ufh_B0k@6vVM=Ri`b1&-+FVchbGW^duRlp_R9=K(20iFSJW z^`|uTcTOG2+>woK8hP{x*WGmZ!`Qk|6Pw1Ko*RQAy>L7YX48LQ`lD^|_Ucb**nQoD zg3@Z7kh(-M5zvN^-%gywBw>Y~>IkU#pB^J6%OrCU{)Q8e?!ryxC{R#epp`Q=b!Mhla#|-)4z!8N! zIyt_t0l4>_J4i9IbeoCR&>#POIDn&`lJDshRK|G}WB`tF%N@7OPnNo~)D;iQ^`~?o zpEq}}n|3>xR!V@KneOvcgnDsLfgPa}^giRnNX)I_iYY8`=Eb^Ye@?H(kGqo*VZ7c#QS8M+uYaa=zs-SB$Jz%<_9h5_?owwsHx{QzcpZ z?{>_3%D&`r-u%zfXHuPu7&D(=vgripGj5>F?>a#>;^llzqc6VLsll@=Ya#KQa~HXM z+Yq;5n)4{VHydl`*%ZirDvK)vr~M+)b&Sb_kJ7f7mA`3EKiKIQ%B#j8PxtHV^oc3g zfR5Sj5B2e8mi0ecQXIXN#7Py$54SJ6PqkSM z`qZ!eWusaN*?9jTY#%JQ-ZVv#W#0wAV7H%L()xxHp50+1z7R@hRM@{PDs11uhJD!7 z3YoyL$DgzDnK^@59A5Z&M-R>|Ct}O2NJwTE63<8Csqc7zTKy|6|NMzOvD5uh^A2%j z`^PW@>M~bF!BxMTCeDLTVo{8wq7bCz%+EeE0(2)>MkQ9p(Os}n(>M|*I z%dwUFzS|ENBpx>EVPvb;peRU3Pu7sIHq@=$IrAo8dJa35Y2O5IQz2o1 zS?8&_G2GnCqO89Aw3kIz;a-ewIJa%6!$LX13k$TC?=Lzcwv!V^%8wS!Rrt3<;Z$A+ zf)|EBUnt_d$mZ~rM+T`sJ0E{%&ziU8JRFg+6gc^C#r)FCoofR?u_Fw=}uMM8?1fSD>lX5zqT=uE6Zv33AV+Vcif$KK@17nB7-=71u_P)uAzoiad z6oM3-V_=4V$X{|fV`-D`#vC*++By!>OG)Qq(!j%-xDMl3i@FeeQmXGKKNRuE*$SZI zd}HL3cuW8ONPN}|QY{#Fd*cpGD>OuGDs0!{z=FMc?~4SYj+ann%@@H)xet4?xicYa zLqA{4?P|&&quZC4qxuMA!Xdf;x4m5ZhO<(|NQ;CekCNkZv(rwbZ`PdoIQ5Io&nqVn zq>QWhTX8X&w9d$nKiyxCsxL zmesCkAT{x7;bDC~WyCA!blnRzCM^HI*>mDzjMV>pu8M*tsGC5(2@&N_)Q}0*e=fGP|UY9!r2E&xByQLXa=S=vO3MNGus`mU47noLM`DtdFw354HcojfC?rs=g zQ@B!HghgW9a3T{hetIH$sOVYy`g0w7S{+UEl~o8pBK$k7D0Lx%TFpaX2rtLxy4lJ% zhv9`_Jl*d_izaLgNPfV0fI7Hq8brchuswfo9N16&uN2a2qkf-s^zIQ|H}RrZ4?k5% z+N^}LwvZl`EJSZW9h$+%@W~B>eHV_iws*Qi;KfpgaiC{R)4Tf+FL|1!W%yD0bW=qIu#J0~^f%boa%$ctL65l=j-hT+aJ$R_ z!@Yi@1GJ@qxEjya2j4*fB3|u6`%s(W)rNA*{Fs5$-7&bQU&W6}^icAtvOdi|7Wz_NNc*9NL~7KK8;#4HPb!q_347gxb)DYq{sd6zruL zB+ms&c^*O<5pM`lEyKSXqI{}^4%pyt&Z;9PJsX} zZs5*JqPuWs2tzJMQ@3>X&&uTlL77nTavn|)s}Mr4eM&Q4=dbU;+}pi2eKyLyKBqb~ ztGvrd-5KKI(ZK_W6=Rq<^RCWh{kw>}l-?&8t8>SN^hiBqt z*yxZVY8P(FR1M%yCj5Rs9rvPWTfbr0)ZQHZS>SEk)_AqT1ArXibZ!ha6;%mJg+Shw zNRy>i{Wj}w($dmx3aDn#{U=a{LR--Rjs-WA^2T!djRFi{>I%U3=CHLI;ZQ6bg1a1 zUSpZeoy!|AF45z1PepFaANIRe7$;Tu3#LDm>w$srprV9Gmoenod{JxpLE%~t=>Ym^ z0?+zoc4mh)*3koK^Fn)8Ug=7MUKbw&TC(Gr*+qost&SHuK#?@RRC7{gihj5p;Vilw zG?f}@tUnK*yU6<=b{dbi4UrSonHd@hicD>%2h4UCg^Gn66;st^SvX^$u8DZQW)9o3 zKoWXoi`DR4gn1(z`RmVF*oj`_ZXV&A@UC3h+q(g%d};ieEXgvH1-vbHH10l8)1vRj zbaHArR37PO+zk7M;pEAQQRj;+W)gxlv0`Vn;fN+eFtV!zG<_MQ+1<=FnBHwcKyU>t zX>(3S*8Z@y^idlR;2_MnU(0sx#WIEik>JI4q>?t9bHyT=JFK;u&~N>sOANPIoNoPu{EF_bgS$XY)^)WC2n{C!tK@ugsi>nBm;->bdLAyI7U%2Jc6taN*(@~*O z^Jl&Vr%L^9PEv5Z6<@9PU9fsyTBq>kz5zMW^C`_xP0_yG+vJc@Jx}qja#+mf272Kh zQC2(I>Q8$E$P^|ntPjgJI?(Ck4a4gab06dM6XL1A0gG~bK`=;6`m?X%=60>aR&+GA zD@VGRa1_P+!{qVAHN!<_(XNLW;%ARH|Dv^i{IOPy;=k5i!TknZ+!LA9=MDTZeRO!v~D`@w&^xIb)?%PwzH1X!bx zsIa4blOaK++(|0|xd$&OZ>%|c73q>_13l}U{ds8*z-}{)y>S-jm`$lUouK>*6pC$P zV!F1Y1*eV@!VLVFXlo;m*Hx?l=m6(3I+$_}f~x9NCq4S*rs7Ot^iMZVuTQ#TSmD%x zO7VSH0?W%Nj!@b47xdWmER3mmJSv-vz)vt&`4^28O#gmf{JN)|gibhsl-a3k3l;6c z^cjb26HHcy3)Ye{a?8HdR6q;6;BfPIhGip+wdk%hLE`t9!A)8Pmqabo5x&RK4Bd%rTTAbhG-=yK7jrUFXY8%~Rp^8u2XE_p=n;xFx}@ZF2KAajp{Rf%3drho zG^FA}K~9nMN?U+CiNU#6{U{FWpn80JLs`i{AJsPSD+h zcj{YbQbQS2PDd#q#f9Q6MY1bJW4sWQApDqh+R3}YE~~X4GEd7mEfHH-mwmkqogYTT zSbMLYxFk3w$tS8!z->AYvw<8|7OEFxk%}|ARI9om$Z#NSwW+*J+lP2V6>_QfI*Bt9 zZ6#3!eH{l*gZI87RAW+=L{OR5YR3(IBAW8i6J#{$$T4l{cKooIidQ}T3Cu*+mnR_S zI`_MuIDt!mk$q9>s{28dL<*GZ??mK1P!PJilCrF9E~G1V^pB5IK30xT!bu_$w={wI zMjV#--g-#Z6dFb0*(RkK_+nUJkYWj+XrO=vh07$>J#%(V{a?Ms(@(FBcM>Ze6A)60 zxS>K@Js^qbq#C{9giL#FEoPM|QBD9=915u1GsF7%LfxTH68P)E<*p*)kdoS+X*Ysy zPfoz8$$Dp63;FI@9iX6t=tzMQu1s2u)P$tlcExHRFq6Pku2BI3S3mGrTHBF|G)ol*@5v{H8~wNtB4{fL_cYU cUYj;+m_g|9IiFVSs`-C#zuUf|y%%o%AJ=dseE Date: Thu, 17 Apr 2025 17:36:23 +0100 Subject: [PATCH 23/38] Remove references to example app from LiveObjects docs The examples for LiveObjects will be added in a separate PR [1] and require a new examples page structure released in production. In order to allow us to release the LiveObjects docs, we remove the references to the example apps for now. Will revert this commit and set the correct links once new examples page structure is released. [1] https://github.com/ably/docs/pull/2437 --- content/liveobjects/index.textile | 7 ------- content/liveobjects/quickstart.textile | 1 - 2 files changed, 8 deletions(-) diff --git a/content/liveobjects/index.textile b/content/liveobjects/index.textile index 1b8a5a1c29..e050d20e9d 100644 --- a/content/liveobjects/index.textile +++ b/content/liveobjects/index.textile @@ -62,10 +62,3 @@ h3(#batch). Batch operations "Batching":/docs/liveobjects/batch enables multiple LiveObjects operations to be grouped into a single channel message, ensuring atomic application of grouped operations. This prevents partial updates of your data and ensures consistency across all users. Batching is particularly useful in scenarios where multiple dependent updates need to be processed together, ensuring a seamless experience for users. - -h2(#examples). Examples - -Take a look at the LiveObjects examples to help you get started: - -* "Voting system powered by LiveCounter":https://examples.ably.dev/liveobjects-live-counter : Demonstrates how to use a LiveCounter to track votes in a realtime poll. -* "Realtime Task Board powered by LiveMap":https://examples.ably.dev/liveobjects-live-map : Demonstrates how LiveMap can be used to store and manage shared list of tasks between users. diff --git a/content/liveobjects/quickstart.textile b/content/liveobjects/quickstart.textile index dc0680f342..612e3e4a2f 100644 --- a/content/liveobjects/quickstart.textile +++ b/content/liveobjects/quickstart.textile @@ -196,5 +196,4 @@ This quickstart introduced the basic concepts of LiveObjects and demonstrated ho * Read more about "LiveCounter":/docs/liveobjects/counter and "LiveMap":/docs/liveobjects/map. * Learn about "Batching Operations":/docs/liveobjects/batch. * Learn about "Objects Lifecycle Events":/docs/liveobjects/lifecycle. -* Explore "LiveObjects examples":https://examples.ably.dev. * Add "Typings":/docs/liveobjects/typing for your LiveObjects. From 5f2b1780fd2a470764156284edbfa2caa37c4df5 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Thu, 17 Apr 2025 17:49:22 +0100 Subject: [PATCH 24/38] Update language data for JavaScript SDK to 2.8 ably-js 2.7.0 was just released [1] with annotations feature. The LiveObjects released should be released in the next 2.8.0 version. [1] https://github.com/ably/ably-js/releases/tag/2.7.0 --- src/data/languages/languageData.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/data/languages/languageData.ts b/src/data/languages/languageData.ts index b4abe681db..2d6fbdb46a 100644 --- a/src/data/languages/languageData.ts +++ b/src/data/languages/languageData.ts @@ -3,13 +3,13 @@ import { LanguageData } from './types'; export default { platform: { - javascript: 2.7, - nodejs: 2.7, + javascript: 2.8, + nodejs: 2.8, }, pubsub: { - javascript: 2.7, - nodejs: 2.7, - react: 2.7, + javascript: 2.8, + nodejs: 2.8, + react: 2.8, csharp: 1.2, flutter: 1.2, java: 1.2, From 60c5585491c5714dcb2c34aa54c00e2fe3d634c0 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 8 Apr 2025 15:11:56 +0100 Subject: [PATCH 25/38] Migrated LiveObjects rest API from the current implementation to OpenAPI --- content/liveobjects/rest-api.textile | 435 ----------------- src/data/nav/liveobjects.ts | 8 +- src/pages/docs/api/liveobjects-rest.tsx | 35 ++ static/open-specs/liveobjects.yaml | 622 ++++++++++++++++++++++++ 4 files changed, 661 insertions(+), 439 deletions(-) delete mode 100644 content/liveobjects/rest-api.textile create mode 100644 src/pages/docs/api/liveobjects-rest.tsx create mode 100644 static/open-specs/liveobjects.yaml diff --git a/content/liveobjects/rest-api.textile b/content/liveobjects/rest-api.textile deleted file mode 100644 index 594db0247f..0000000000 --- a/content/liveobjects/rest-api.textile +++ /dev/null @@ -1,435 +0,0 @@ ---- -title: REST API Reference -meta_description: "Ably provides the raw REST API for interacting with the LiveObjects stored on a channel." -meta_keywords: "REST API, REST, LiveObjects, objects" -section: api -index: 100 ---- - -h2(#fetching-objects). Fetching objects - -There are three APIs for fetching objects stored on a channel: - -1. List - returns a list of objects. -2. Object - returns the objects in a tree starting from the given object. -3. Compact - returns the objects in a compact tree format. - -h3(#list-objects). List objects - -Use the list endpoint to fetch a list of objects stored on the channel. - -h6. GET rest.ably.io/channels//objects - -Returns the IDs of the live objects store on the channel. - -bc(json). [ - "root", - "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000", - "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" -] - -To include values, set the @values=true@ query parameter. - -h6. GET rest.ably.io/channels//objects?values=true - -bc(json). [ - { - "objectId": "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000", - "map": { - "entries": { - "myMapKey": { "data": { "string": "my map value" } }, - "myObjectRef": { "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" } } - } - } - }, - { - "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000", - "counter": { "data": { "number": 5 } } - } -] - -h3(#data-values). Data values - -In the list API, data values represent a concrete piece of data (a number, a string, etc) or a reference to another object. The key in the data value indicates the type that you can expect to receive in the value. - -bc(json). { "data": { "number" : 4 }} -{ "data": { "string" : "Ably Pub/Sub" }} -{ "data": { "boolean" : true }} -{ "data": { "bytes": "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=", "encoding": "base64" }} -{ "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" }} - -Maps are made of entries, which are a user-defined key (e.g. "myMapKey") and a data value. Counters are data values containing a number. - -h3(#query-params). Query parameters - -|_. Param |_. Description | -|values|@true@ or @false@, default is @false@. Include the values in the response. Setting this to @false@ returns a list objectIds only. | -|limit |Set the number of objects to be returned in each page. | -|cursor | The cursor used for "pagination.":/docs/api/rest-api#pagination | -|metadata| @true@ or @false@, default is @false@. Include "object metadata":#metadata in the response. | - -h3(#get-object). Get an object - -Use the object API to fetch a live object stored on the channel in a tree structure. - -h6. GET rest.ably.io/channels//objects/ - -bc(json). { - "objectId": "root", - "map": { - "entries": { - "myMapKey": { "data": { "string": "my map value" }}, - "myObjectRef": { - "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" } - } - } - } -} - -To replace the object references with the actual values (inlined), set the @children=true@ query parameter. This query param causes the content of the objects to be included in the tree response, instead of just the objectIds. - -h6. GET rest.ably.io/channels//objects/?children=true - -bc(json). { - "objectId": "root", - "map": { - "entries": { - "myMapKey": { "data": { "string": "my map value" }}, - "myObjectRef": { - "data": { - "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000", - "counter": { "data": { "number": 5 }} - } - } - } - } -} - -Use @root@ as the objectId in the URL to get the full object tree, or any other objectId to fetch a subset of the tree. - -The traverse API also supports requesting metadata with @metadata=true@ query param. - -You can limit the number of objects included in the response using the @limit@ query param. - -h3(#get-compact-object). Get a compact object - -The compact API returns a tree structure of the objects in a concise format that's easier to unmarshal into data types that represent your state. To fetch the full object tree, use the objectId @root@. - -h6. GET rest.ably.io/channels//objects//compact - -bc(json). { - "myMapKey": "my map value", - "myCounter": 5 - "myNestedMap": { - "nestedKey": "nested value" - } -} - - -Map keys will be inlined as json object keys, maps will be json objects, and counters will be inlined directly with the counter value. - -You can limit the number of objects included in the response using the @limit@ query param. - -Note, cyclic and diamond references will be included as objectId references rather than including a single object in the response more than once. The compact API response schema does not distinguish between a string value and an objectId. - -h3(#metadata). Metadata - -Object metadata describes the internal details of an object. There are two metadata fields that are common to all objects: - -|_. Field |_. Description | -| Tombstone | true or false, indicates if the object has been deleted. Objects will exist as tombstones for a short while to ensure they remain deleted and are not accidentally created by a lagging live objects client. | -|SiteTimeserials |a map of sites, and the last operation applied to this object from that site. | - -Maps have additional metadata attached to their map entries. - -|_. Field |_. Description | -| MapSemantics | Indicates the conflict resolution method used in this map. | -| Timeserial | The last operation applied to this map key, used to preserve the map semantics.| -| Tombstone | true or false, indicates if the map entry has been deleted. Tombstoned map values are not included in the responses by default or when using the @metadata=false@ query param. | - -Example object with metadata included: - -bc(json). { - "objectId": "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000", - "map": { - "mapSemantics": "LWW", - "entries": { - "myMapKey": { - "timeserial": "...", - "tombstone": false, - "data": { "string": "my map value" } - }, - "myObjectRef": { - "timeserial": "...", - "tombstone": true, - "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" } - } - } - } -} - -h3(#tombstones). Tombstones - -Tombstones are used on objects and on map entires to indicate that the object or map entry has been deleted. This protects against lagging live objects clients from re-introducing a deleted value by accident. - -Tombstone objects are not included by default, but can be enabled using @metadata=true@ query param. - -Tombstone map entry keys are included by default, but the values are not. The key will be present in the entries object but with a null value e.g. @myKey: null@. Tombstoned map entry values can be included using the @metadata=true@ flag. - -h3(#cyclic-and-diamond-references). Cyclic and diamond references - -In the List, Get object, and Get compact object APIs objects will only be included in the response once. For cyclic or diamond references, the later references to any object that was already included will have an objectIds only. The reference will be included even if you request the data to be inlined using query params, as objects will only appear in the response once. - - -h2(#issuing-operations). Issuing operations - -h6. POST rest.ably.io/channels//objects - -h3(#maps). Map operations - -h4(#map-create). Create a map - -bc(json). { - "operation": "MAP_CREATE", - "data": { - "myMapKey": {"string": "myMapValue"}, - "myOtherKey": {"boolean": true} - } -} - -The @data@ field is made up of your map keys, and a "data" value [^link to above]. You do not have to set a value on the map when you create it. - -You do not have to provide an objectId when you create a map. The server will assign the correct objectId and return it in the response: - -bc(json). { - "channel": "", - "objectIds": ["map:Pm/NQtjxtARMnLFMUiw8U+eeiayy0kClduUZlH0ag30@1742555472000"] -} - -You can create an objectId and provide it in the create request. The server will validate the objectId is the correct format. See the "ObjectId":#object-id-format format section. - -bc(json). { - "operation": "MAP_CREATE", - "objectId": "map:99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_-yXQE@1742207566771", - "nonce": "myNonce", - "data": {"myMapKey":{"string":"myMapValue"}} -} - -h4(#map-set). Set a value on a map - -To set a value on a map, you need to provide the objectId of the map you wish to set the key and value on. The data value consists of a @key@ and @value@. Like before the @value@ carries an object that indicates the type of the value being set. - -bc(json). { - "operation": "MAP_SET", - "objectId": "map:99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_@1742207566771", - "data": {"key":"foo", "value":{"string":"bar"}} -} - -h4(#map-remove). Remove a value from a map - -To remove a key and value from a map, provide the objectId of the map you wish to modify, and the key to remove. - -bc(json). { - "operation": "MAP_REMOVE", - "objectId": "map:99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_@1742207566771", - "data": {"key":"myMapKey"} -} - -h3(#counters). Counter operations - -h4(#counter-create). Create a counter - -The counter create takes a @data@ value with a @number@ field. This number is the initial value that the counter will be initialised to. - -bc(json). { - "operation": "COUNTER_CREATE", - "data": { "number": 3.1415926 } -} - - -You can create a counter with an objectId, rather than relying on the server to assign the objectId. - -Response: - -bc(json). { - "channel": "", - "objectIds": ["counter:u41d1-DfkEt1AtbyJUSUJn3qAFblVVGmx5Dpg-ToCeI@1734628392000"] -} - -h4(#counter-create). Increment a counter - -To increment a counter, you must provide the objectId of the counter that you are going to increment. You can increment by a negative value. - -bc(json). { - "operation": "COUNTER_INC", - "objectId": "counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000", - "data": {"number": 2} -} - -h3(#remove-object). Remove an object - -There is no explicit delete operation for objects. Objects that are reachable from the root map are kept, any object that is not reachable from the root map is elligible for periodic garbage collection. - -To remove an object, update the object tree to remove any reference to that objectId, and the object will be elligible for garbage collection. - -h2(#object-id-format). ObjectId format - -An objectId is made of the following components: - -bc. objectType:base64hash@millisecondTimestamp -// Example: -counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000 - -The object types are @map@ or @counter@. - -The millisecond timestamp is "now". There is a small leeway to compensate for server clocks being out of sync. You can fetch the ably server time using "@rest.ably.io/time@":/docs/api/rest-api#time. - -The base64 raw url encoding hash is made of @initialValue:nonce@. The initial value is the raw bytes taken from the @data@ field on create operations. The @nonce@ is any random string. - -Examples: - -|_. Initial value |_. Nonce |_. Result hash | -|@{"value":3.1415926539}@ | @nonce@ | @u41d1-DfkEt1AtbyJUSUJn3qAFblVVGmx5Dpg-ToCeI@ | -|@{"foo":{"string":"bar"}}@ | @nonce@ | @ME2yWbb_6sK5AlwxklHA8mTBPxPZx9iyW2Zk6rKJfRs@ | -|@{"myMapKey":{"string":"myMapValue"}}@ | @myNonce@ |@99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_-yXQE@ | - -Note: the initial value is space and case sensitive. - -The Operations API accepts the "data" field as either a json object, or as a string with an encoding field. For example, this request passes the data field for a counter as a string with json encoding. - -bc(json). { - "operation": "COUNTER_CREATE", - "data": "{ \"number\": 3.1415926 }", - "encoding": "json" -} - -h2(#batch-operations). Batch operations - -You can pass a list of operations to the operations endpoint. - -h6. POST rest.ably.io/channels//objects - -bc(json). [ - { - "operation": "MAP_SET" - "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", - "data": {"key": "isActive", "value": { "boolean": true }} - }, - { - "operation": "COUNTER_INC" - "objectId": "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmTrNEguJXo@1742481614000", - "data": {"number": 1} - } -] - -The response is the same as the single operation endpoint: - -bc(json). { - "channel": "", - "objectIds": [ - "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", - "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmTrNEguJXo@1742481614000" - ] -} - -It map be useful to generate an objectId client-side, so that you can create an object and modify it in the same batch operation. For example; create a map and set values on it in the same operation. - -Batches of operations issued in the REST API will remain a batch, and will be passed to clients as a batch. Other operations issued against that object will not be interleaved between operations within the same batch. - -h2(#path-operations). Path operations - -h6. POST rest.ably.io/channels//objects - -Use path operations to issue operations against objects based on their referenced location in the tree of objects stored on the channel. -Paths must start with a key in the 'root' map, and then reference keys in nested maps, until the target location. - -For example, increment the @likes@ counter in the @reactions@ map by 3. - -bc(json). { - "path": "reactions.likes", - "operation": "COUNTER_INC", - "data": { "number": 3 } -} - -Example object tree this path could match: - -bc(json). { - "reactions": { - "likes": - } -} - -Here, the root map contains a @reactions@ key, which has a map value. The @reactions@ map contains a single key @likes@, which has a counter value. So incrementing @reactions.likes@ will increment the @likes@ counter in the @reactions@ map. - -Response: - -bc(json). { - "channel": "", - "objectIds": [""] -} - -You can create an object, like a map or a counter, and assign it to a path in a single operation. For example, the following operation will create a counter in the @reactions@ map under the key @likes@. - -bc(json). { - "path": "reactions.likes", - "operation": "COUNTER_CREATE" -} - -When creating operations with a path, all of objects up to the last path component must exist. In the example above, the @reactions@ map must already exist on the root map for us to set a counter in it under the @likes@ key. -Create operations cannot: overwrite other objects that already exist, or use wildcards. - -h3(#wildcards). Wildcard paths - -You can issue a single operation against multiple objects at once using the wildcard @*@. For example, increment all @reactions@ counters by 1. - -bc(json). { - "path": "reactions.*", - "operation": "COUNTER_INC", - "data": { "number": 1 } -} - -Wildcards can be included at the end or in the middle of paths and will match exactly one level in the object tree. For example, this path increments all objects reachable from the root map that have a @likes@ counter: - -bc(json). { - "path": "*.likes", - "operation": "COUNTER_INC", - "data": { "number": 1 } -} - -If your map keys (that make up the path) already contain a @.@, you can escape the path using a @\.@ - -For example, increment the counter stored at the @foo.bar@ key on the root map: - -bc(json). { - "path": "foo\.bar", - "operation": "COUNTER_INC", - "data": { "number": 1 } -} - -h2(#idempotent-operations). Idempotent operations - -All operations support an @id@ field. Operations are deduplicated using "idempotent publishing":/docs/pub-sub/advanced#idempotency based on the operation's "id" field. - -bc(json). { - "id": "myIdempotencyKey", - "operation": "MAP_SET" - "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", - "data": {"key": "isActive", "value": { "boolean": true }} -} - -Batches of operations can be made idempotent using a compound key based on the format @:@. The index is the order of the operation in the batch. The base of the @id@ must be the same across all operations in the batch. - -bc(json). [ - { - "id": "myIdempotencyKey:0", - "operation": "MAP_SET" - "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", - "data": {"key": "isActive", "value": { "boolean": true }} - }, - { - "id": "myIdempotencyKey:1", - "operation": "COUNTER_INC" - "objectId": "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmTrNEguJXo@1742481614000", - "data": {"number": 1} - } -] diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index 11bdecd7fc..37881a84a8 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -61,15 +61,15 @@ export default { { name: 'API References', pages: [ - { - link: '/docs/liveobjects/rest-api', - name: 'REST API', - }, { link: 'https://ably.com/docs/sdk/js/v2.0/interfaces/ably.Objects.html', name: 'JavaScript SDK', external: true, }, + { + link: '/docs/api/liveobjects-rest', + name: 'REST API', + }, ], }, ], diff --git a/src/pages/docs/api/liveobjects-rest.tsx b/src/pages/docs/api/liveobjects-rest.tsx new file mode 100644 index 0000000000..ac1ce44f93 --- /dev/null +++ b/src/pages/docs/api/liveobjects-rest.tsx @@ -0,0 +1,35 @@ +import { Link, withAssetPrefix } from 'gatsby'; +import Icon from '@ably/ui/core/Icon'; +import { useSiteMetadata } from '../../../hooks/use-site-metadata'; +import { Head } from '../../../components/Head'; +import { Loader } from '../../../components/Redoc'; + +const LiveObjectsRestApi = () => { + const { canonicalUrl } = useSiteMetadata(); + const canonical = canonicalUrl('/docs/api/liveobjects-rest'); + const meta_title = 'LiveObjects REST API'; + const meta_description = 'Ably provides the raw REST API for interacting with the LiveObjects stored on a channel.'; + const liveObjectsRest = withAssetPrefix('/open-specs/liveobjects.yaml'); + + return ( + <> + +
+
+ + + Ably LiveObjects + + / {meta_title} +
+
+ + + ); +}; + +export default LiveObjectsRestApi; diff --git a/static/open-specs/liveobjects.yaml b/static/open-specs/liveobjects.yaml new file mode 100644 index 0000000000..ca531b50f0 --- /dev/null +++ b/static/open-specs/liveobjects.yaml @@ -0,0 +1,622 @@ +openapi: 3.0.0 +info: + title: Ably LiveObjects REST API + version: 1.0.0 + description: | + # LiveObjects API + + LiveObjects provides a set of purpose-built APIs and data structures to handle the complexities of persisting and synchronizing state, freeing you to focus on building features instead of managing concurrency or conflict resolution. + + LiveObjects enables you to store data as "objects" on a channel. These objects are automatically synchronized in realtime across all connected clients, and any conflicts that arise from concurrent updates are seamlessly resolved in the background. +servers: + - url: https://main.realtime.ably.net/ + description: Production server for Ably REST API + +paths: + /channels/{channelId}/objects: + get: + summary: Get a tree of objects + description: | + Fetches the list of objects stored on the channel.

+ + In the list API, data values represent a concrete piece of data (a number, a string, etc) or a reference to another object. The key in the data value indicates the type that you can expect to receive in the value.

+ + ```json + { "data": { "number" : 4 }} + { "data": { "string" : "Ably Pub/Sub" }} + { "data": { "boolean" : true }} + { "data": { "bytes": "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=", "encoding": "base64" }} + { "data": { + "objectId": + "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" + }} + ``` + +
Maps are made of entries, which are a user-defined key (e.g. `myMapKey`) and a data value. Counters are data values containing a number. + tags: + - List objects + parameters: + - name: channelId + in: path + required: true + description: The channel ID. + schema: + type: string + example: "my-app:likes:counter" + - name: values + in: query + required: false + description: Include the values in the response. Setting this to false returns a list of `objectIds` only. + schema: + type: boolean + default: false + - name: limit + in: query + required: false + description: Set the number of objects to be returned in each page. + schema: + type: integer + - name: cursor + in: query + required: false + description: The cursor used for pagination. + schema: + type: string + - name: metadata + in: query + required: false + description: Include object metadata in the response. + schema: + type: boolean + default: false + responses: + '200': + description: List of retrieved objects or object references. + content: + application/json: + schema: + oneOf: + - type: array + items: + $ref: '#/components/schemas/ObjectWithDetails' + - type: array + items: + type: string + example: "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" + examples: + objectArray: + summary: Full response with object values (url param values=true) + value: + - objectId: "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000" + map: + entries: + myMapKey: { data: { string: "my map value" }} + myBoolean: { data: { boolean: true }} + myEncodedKey: { data: { bytes: "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=", encoding: "base64" }} + - objectId: "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" + map: + entries: + counter: { data:{ number: 5 }} + myObjectRef: { data: { objectId: "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" }} + refArray: + summary: Just object references (url param values=false or not set) + value: + - "root" + - "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000" + - "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" + '401': + description: Authentication failed + '404': + description: Channel not found. + '500': + description: Internal server error. + post: + summary: Create or update objects. + description: | + Allows creating or updating objects on the channel by providing the operation and data in the request body. If `objectId` is not provided, the server will generate one and return it in the response. + tags: + - Create or update objects + parameters: + - name: channelId + in: path + required: true + description: The channel ID. + schema: + type: string + example: "my-app:likes:counter" + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + properties: + id: + $ref: '#/components/parameters/idempotentParameter' + path: + $ref: '#/components/parameters/pathParameter' + operation: + $ref: '#/components/parameters/operationParameter' + data: + $ref: '#/components/parameters/dataParameter' + - type: array + items: + type: object + properties: + id: + $ref: '#/components/parameters/idempotentParameter' + path: + $ref: '#/components/parameters/pathParameter' + operation: + $ref: '#/components/parameters/operationParameter' + data: + $ref: '#/components/parameters/dataParameter' + examples: + singleOperation: + summary: Single operation example + value: + id: "myIdempotencyKey" + operation: "MAP_SET" + objectId: "map:cwhvmsq21tXtFDS02TQqPdIh..." + data: + key: "isActive" + value: { "boolean": true } + batchOperation: + summary: Batch operation example + value: + - id: "myIdempotencyKey:0" + operation: "MAP_SET" + objectId: "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc..." + data: + key: "isActive" + value: { "boolean": true } + - id: "myIdempotencyKey:1" + operation: "COUNTER_INC" + objectId: "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmT..." + data: + number: 1 + createMap: + summary: Create a map + value: + operation: MAP_CREATE + data: + myMapKey: { string: "myMapValue" } + myOtherKey: { boolean: true } + setValueOnMap: + summary: Set a value on a map + value: + operation: MAP_SET + objectId: "map:99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcE..." + data: + key: "foo" + value: { string: "bar" } + removeValueFromMap: + summary: Remove a value from a map + value: + operation: MAP_REMOVE + objectId: "map:99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_@174220..." + data: + key: "myMapKey" + createCounter: + summary: Create a counter + value: + operation: COUNTER_CREATE + data: + number: 3.1415926 + incrementCounter: + summary: Increment a counter + value: + operation: COUNTER_INC + objectId: "counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlg..." + data: + number: 2 + responses: + '200': + description: Successfully created or updated objects. + content: + application/json: + schema: + type: object + properties: + channel: + type: string + description: The channel ID. + example: "" + objectIds: + type: array + items: + type: string + description: The list of object IDs created or updated. + example: ["map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000"] + '400': + description: Invalid request. + /channels/{channelId}/objects/{objectId}: + get: + summary: Gets an object using an object ID. + description: | + Fetch a LiveObject stored on the channel in a tree structure. + tags: + - Get object by ID + parameters: + - name: channelId + in: path + required: true + description: The channel ID. + schema: + type: string + example: "my-app:likes:counter" + - name: objectId + in: path + required: true + description: The object ID. + schema: + type: string + example: "object-123" + default: root + - name: children + in: query + required: false + description: Causes the content of the objects to be included in the tree response, instead of just the objectIds. + schema: + type: boolean + default: false + responses: + '200': + description: Retrieve a LiveObject stored on the channel in a tree structure. + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectWithDetails' + examples: + withChildren: + summary: Expanded object with embedded children + value: + objectId: "root" + map: + entries: + myMapKey: + data: + string: "my map value" + myObjectRef: + data: + objectId: "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" + counter: + data: + number: 5 + withoutChildren: + summary: Object references only + value: + objectId: "root" + map: + entries: + myMapKey: + data: + string: "my map value" + myObjectRef: + data: + objectId: "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" + + '400': + description: Invalid request. + /channels/{channelId}/objects/{objectId}/compact: + get: + summary: Get a compact tree of objects + description: | + Returns a tree structure of the objects in a concise format that's easier to unmarshal into data types that represent your state. To fetch the full object tree, use the objectId root. + tags: + - Get compact object + parameters: + - name: channelId + in: path + required: true + description: The channel ID. + schema: + type: string + example: "my-app:chat:room-123" + - name: objectId + in: path + required: true + description: The object ID. + default: root + schema: + type: string + example: "object-123" + responses: + '200': + description: Successfully retrieved the compact object tree. + content: + application/json: + schema: + type: object + additionalProperties: true + example: + myMapKey: "my map value" + myCounter: 5 + myFlag: true + myNestedMap: + nestedKey: "nested value" + +tags: + - name: List objects + description: | + Operations related to listing objects on a channel. + - name: Create or update objects + description: | + Operations related to creating or updating objects on a channel. + - name: Get object by ID + description: | + Operations related to retrieving an object by its ID. + - name: Get compact object + description: | + Operations related to retrieving a compact representation of an object tree. + - name: objectId format + description: | + An `objectId` is made of the following components:

+ + ``` + objectType:base64hash@millisecondTimestamp` + // Example: + counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000 + ``` + +
The object types are `map` or `counter`.

+ + The millisecond timestamp is "now". There is a small leeway to compensate for server clocks being out of sync. You can fetch the ably server time using `rest.ably.io/time`.

+ + The base64 raw url encoding hash is made of `initialValue:nonce`. The initial value is the raw bytes taken from the data field on create operations. The `nonce` is any random string. + + | initial value | nonce | result hash | + |-------------------------|--------|-------------------------------------------------| + | `{"value":3.1415926539}` | `nonce` | `u41d1-DfkEt1AtbyJUSUJn3qAFblVVGmx5Dpg-ToCeI` | + | `{"foo":{"string":"bar"}}` | `nonce` | `ME2yWbb_6sK5AlwxklHA8mTBPxPZx9iyW2Zk6rKJfRs` | + | `{"myMapKey":{"string":"myMapValue"}}` | `myNonce` | `99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_-yXQE` | + + > **Note:** the initial value is space and case sensitive. + + The Operations API accepts the “data” field as either a json object, or as a string with an encoding field. For example, this request passes the data field for a counter as a string with json encoding.

+ + ```json + { + "operation": "COUNTER_CREATE", + "data": "{ \"number\": 3.1415926 }", + "encoding": "json" + } + ``` + - name: Object metadata + description: | + Object metadata describes the internal details of an object. There are two metadata fields that are common to all objects: + + | Field | Description | + |-------|-------------| + | Tombstone | true or false, indicates if the object has been deleted. Objects will exist as tombstones for a short while to ensure they remain deleted and are not accidentally created by a lagging LiveObjects client. | + | SiteTimeserials | a map of sites, and the last operation applied to this object from that site. | + + Maps have additional metadata attached to their map entries. + + | Field | Description | + | MapSemantics | Indicates the conflict resolution method used in this map. | + | Timeserial | The last operation applied to this map key, used to preserve the map semantics.| + | Tombstone | true or false, indicates if the map entry has been deleted. Tombstoned map values are not included in the responses by default or when using the @metadata=false@ query param. | + + Example object with metadata included: + + ```json + { + "objectId": "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000", + "map": { + "mapSemantics": "LWW", + "entries": { + "myMapKey": { + "timeserial": "...", + "tombstone": false, + "data": { "string": "my map value" } + }, + "myObjectRef": { + "timeserial": "...", + "tombstone": true, + "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" } + } + } + } + } + ``` + + - name: Tombstones + description: | + Tombstones are used on objects and on map entires to indicate that the object or map entry has been deleted. This protects against lagging LiveObjects clients from re-introducing a deleted value by accident. + + Tombstone objects are not included in API responses by default, but can be enabled using `metadata=true` query param. + + Tombstone map entry keys are included by default, but the values are not. The key will be present in the entries object but with a null value e.g. `myKey: null`. Tombstoned map entry values can be included using the `metadata=true` flag. + + - name: Cyclic and diamond references + description: | + In the List, Get object, and Get compact object APIs objects will only be included in the response once. For cyclic or diamond references, the later references to any object that was already included will have an objectIds only. The reference will be included even if you request the data to be inlined using query params, as objects will only appear in the response once. + +components: + schemas: + ObjectWithDetails: + type: object + required: + - objectId + properties: + objectId: + $ref: '#/components/schemas/ObjectId' + map: + $ref: '#/components/schemas/Map' + counter: + $ref: '#/components/schemas/Counter' + ObjectId: + type: string + example: "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000" + Map: + type: object + properties: + entries: + type: object + additionalProperties: + type: object + properties: + data: + type: object + oneOf: + - properties: + string: + type: string + example: "my map value" + - properties: + objectId: + $ref: '#/components/schemas/ObjectId' + Counter: + type: object + properties: + objectId: + $ref: '#/components/schemas/ObjectId' + data: + type: object + properties: + number: + type: number + example: 5 + parameters: + idempotentParameter: + name: id + in: query + required: false + description: | + Optional: Only use when requiring idempotent publishing. All operations support an id field. Operations are deduplicated using idempotent publishing based on the operation's `id` field.

+ + ```json + { + "id": "myIdempotencyKey", + "operation": "MAP_SET" + "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", + "data": {"key": "isActive", "value": { "boolean": true }} + } + ``` + +
Batches of operations can be made idempotent using a compound key based on the format `:`. The index is the order of the operation in the batch. The base of the `id` must be the same across all operations in the batch.

+ + ```json + [ + { + "id": "myIdempotencyKey:0", + "operation": "MAP_SET" + "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", + "data": {"key": "isActive", "value": { "boolean": true }} + }, + { + "id": "myIdempotencyKey:1", + "operation": "COUNTER_INC" + "objectId": "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmTrNEguJXo@1742481614000", + "data": {"number": 1} + } + ] + ``` + pathParameter: + name: path + in: query + required: false + description: | + Optional: Only use when using path operations. Path operations issue operations against objects based on their referenced location in the tree of objects stored on the channel.

+ + Paths must start with a key in the 'root' map, and then reference keys in nested maps, until the target location.

+ + For example, increment the likes counter in the reactions map by 3:

+ + ```json + { + "path": "reactions.likes", + "operation": "COUNTER_INC", + "data": { "number": 3 } + } + ``` + +
You can issue a single operation against multiple objects at once using the wildcard `*`. For example, increment all reactions counters by 1.

+ + ```json + { + "path": "reactions.*", + "operation": "COUNTER_INC", + "data": { "number": 1 } + } + ``` + +
Wildcards can be included at the end or in the middle of paths and will match exactly one level in the object tree. For example, this path increments all objects reachable from the root map that have a likes counter:

+ + ```json + { + "path": "*.likes", + "operation": "COUNTER_INC", + "data": { "number": 1 } + } + ``` + schema: + type: string + example: "reactions.likes" + operationParameter: + name: operation + in: query + required: true + description: | + Specifies the operation to perform. Supported values include:

+ - `MAP_CREATE`: Create a map object. + - `MAP_SET`: Set a key-value pair in a map object. + - `MAP_REMOVE`: Remove a key-value pair from a map object. + - `COUNTER_CREATE`: Create a counter object initialized to a specific value. + - `COUNTER_INC`: Increment a counter object by a specified value (can be negative). + schema: + type: string + enum: + - MAP_CREATE + - MAP_SET + - MAP_REMOVE + - COUNTER_CREATE + - COUNTER_INC + example: COUNTER_INC + dataParameter: + name: data + in: query + required: true + description: | + The data to be created or updated. The structure depends on the operation being performed. Examples include:

+ + ```json + { "data": { "number": 4 }} + { "data": { "string": "Ably Pub/Sub" }} + { "data": { "boolean": true }} + { "data": { "bytes": "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=", "encoding": "base64" }} + { "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" }} + ``` + +
Maps are made of entries, which are a user-defined key (e.g., `myMapKey`) and a data value. Counters are data values containing a number. + schema: + type: object + additionalProperties: + type: object + properties: + number: + type: number + description: For COUNTER_CREATE or COUNTER_INC, this is the value. + example: 3 + string: + type: string + description: A string value. + example: "Ably Pub/Sub" + boolean: + type: boolean + description: A boolean value. + example: true + bytes: + type: string + description: A base64-encoded string. + example: "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=" + encoding: + type: string + description: The encoding type for the bytes field. + example: "base64" + objectId: + type: string + description: A reference to another object. + example: "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" From fc026f1e57df609a068e3650e9a1994f36ec8c0f Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 22 Apr 2025 15:23:02 +0100 Subject: [PATCH 26/38] Update Liveobjects REST API to have http responses --- static/open-specs/liveobjects.yaml | 806 ++++++++++++++++++++++++++++- 1 file changed, 785 insertions(+), 21 deletions(-) diff --git a/static/open-specs/liveobjects.yaml b/static/open-specs/liveobjects.yaml index ca531b50f0..712b753079 100644 --- a/static/open-specs/liveobjects.yaml +++ b/static/open-specs/liveobjects.yaml @@ -105,11 +105,40 @@ paths: - "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000" - "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" '401': - description: Authentication failed + description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/AuthError' + - $ref: '#/components/schemas/ClientRestrictionNotSatisfied' + - $ref: '#/components/schemas/InvalidTokenKey' + - $ref: '#/components/schemas/MalformedCredential' + - $ref: '#/components/schemas/OperationObjectSubscribeUnauthorized' + '403': + description: Forbidden + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ApplicationDisabled' + - $ref: '#/components/schemas/ChannelStateNotEnabled' + - $ref: '#/components/schemas/StateOperationsOnlyAppliedOnRegularChannels' '404': - description: Channel not found. + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/AppNotFound' + '429': + description: Too Many Requests + $ref: '#/components/schemas/RateLimitExceeded' '500': description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/error' post: summary: Create or update objects. description: | @@ -230,7 +259,40 @@ paths: description: The list of object IDs created or updated. example: ["map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000"] '400': - description: Invalid request. + description: Bad request + content: + application/json: + schema: + "$ref": "#/components/schemas/CreateOrUpdateObjectsBadRequest" + '401': + description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/AuthError' + - $ref: '#/components/schemas/ClientRestrictionNotSatisfied' + - $ref: '#/components/schemas/InvalidTokenKey' + - $ref: '#/components/schemas/MalformedCredential' + - $ref: '#/components/schemas/OperationObjectSubscribeUnauthorized' + '403': + description: Forbidden + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ApplicationDisabled' + - $ref: '#/components/schemas/ChannelStateNotEnabled' + - $ref: '#/components/schemas/StateOperationsOnlyAppliedOnRegularChannels' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/AppNotFound' + '429': + description: Too many requests + $ref: '#/components/schemas/RateLimitExceeded' /channels/{channelId}/objects/{objectId}: get: summary: Gets an object using an object ID. @@ -296,9 +358,37 @@ paths: myObjectRef: data: objectId: "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" - - '400': - description: Invalid request. + '401': + description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/AuthError' + - $ref: '#/components/schemas/ClientRestrictionNotSatisfied' + - $ref: '#/components/schemas/InvalidTokenKey' + - $ref: '#/components/schemas/MalformedCredential' + - $ref: '#/components/schemas/OperationObjectSubscribeUnauthorized' + '403': + description: Forbidden + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ApplicationDisabled' + - $ref: '#/components/schemas/ChannelStateNotEnabled' + - $ref: '#/components/schemas/StateOperationsOnlyAppliedOnRegularChannels' + '404': + description: Not found + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/AppNotFound' + - $ref: '#/components/schemas/ObjectNotFound' + '429': + description: Too many requests + $ref: '#/components/schemas/RateLimitExceeded' /channels/{channelId}/objects/{objectId}/compact: get: summary: Get a compact tree of objects @@ -336,6 +426,40 @@ paths: myFlag: true myNestedMap: nestedKey: "nested value" + '400': + description: Bad request + $ref: '#/components/schemas/TombstoneObjectError' + '401': + description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/AuthError' + - $ref: '#/components/schemas/ClientRestrictionNotSatisfied' + - $ref: '#/components/schemas/InvalidTokenKey' + - $ref: '#/components/schemas/MalformedCredential' + - $ref: '#/components/schemas/OperationObjectSubscribeUnauthorized' + '403': + description: Forbidden + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ApplicationDisabled' + - $ref: '#/components/schemas/ChannelStateNotEnabled' + - $ref: '#/components/schemas/StateOperationsOnlyAppliedOnRegularChannels' + '404': + description: Not found + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/AppNotFound' + - $ref: '#/components/schemas/ObjectNotFound' + '429': + description: Too many requests + $ref: '#/components/schemas/RateLimitExceeded' tags: - name: List objects @@ -436,20 +560,316 @@ tags: components: schemas: - ObjectWithDetails: + ApplicationDisabled: + title: Application disabled type: object + properties: + message: + type: string + description: The error message. + example: "Application disabled." + code: + type: integer + description: The HTTP status code returned. + example: 403 + statusCode: + type: integer + description: The Ably error code. + nullable: true + href: + type: string + description: The URL to documentation about the error code. + nullable: true + details: + type: object + nullable: true + description: Any additional details about the error message. required: - - objectId + - message + - code + AuthError: + title: Auth error + description: Authentication error. + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "Token expired, token revoked, or client restriction not satisfied." + AppNotFound: + title: App not found + type: object + properties: + message: + type: string + description: The error message. + example: "Application not found." + code: + type: integer + description: The HTTP status code returned. + example: 404 + statusCode: + type: integer + description: The Ably error code. + nullable: true + href: + type: string + description: The URL to documentation about the error code. + nullable: true + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + ChannelStateNotEnabled: + title: Channel state not enabled + type: object + properties: + message: + type: string + description: The error message. + example: "Channel state is not enabled for this app." + code: + type: integer + description: The HTTP status code returned. + example: 403 + statusCode: + type: integer + description: The Ably error code. + nullable: true + href: + type: string + description: The URL to documentation about the error code. + nullable: true + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + ClientRestrictionNotSatisfied: + description: Client restriction not satisfied. + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "Client restriction not satisfied." + ClientSpecifiedMessageIdInvalid: + type: object + properties: + message: + type: string + example: "Client-specified message ID cannot be empty or does not match the required format for batches." + code: + type: integer + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 40031 + href: + type: string + description: The URL to documentation about the error code. + example: https://help.ably.io/error/40031 + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + Counter: + type: object properties: objectId: $ref: '#/components/schemas/ObjectId' - map: - $ref: '#/components/schemas/Map' - counter: - $ref: '#/components/schemas/Counter' - ObjectId: - type: string - example: "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000" + data: + type: object + properties: + number: + type: number + example: 5 + CreateOrUpdateObjectsBadRequest: + oneOf: + - $ref: '#/components/schemas/FailureDecodingRequestBody' + - $ref: '#/components/schemas/InvalidObjectId' + - $ref: '#/components/schemas/InvalidObjectMessage' + - $ref: '#/components/schemas/NoOperationsInRequest' + - $ref: '#/components/schemas/ObjectIdOrPathRequired' + - $ref: '#/components/schemas/UnknownOperationType' + - $ref: '#/components/schemas/OperationPathNotProcessable' + - $ref: '#/components/schemas/ClientSpecifiedMessageIdInvalid' + - $ref: '#/components/schemas/FailedToUnmarshalOperationData' + - $ref: '#/components/schemas/NoObjectsMatchedPath' + - $ref: '#/components/schemas/UnableToUnquoteJsonData' + - $ref: '#/components/schemas/ObjectsLimitExceeded' + discriminator: + propertyName: statusCode + mapping: + 'Failure decoding request body': '#/components/schemas/FailureDecodingRequestBody' + 'Failed to unmarshal operation data': '#/components/schemas/FailedToUnmarshalOperationData' + 'No operations in request': '#/components/schemas/NoOperationsInRequest' + 'Operation Id or path required': '#/components/schemas/ObjectIdOrPathRequired' + 'No objects matched path': '#/components/schemas/NoObjectsMatchedPath' + 'Unknown operation type': '#/components/schemas/UnknownOperationType' + 'Unable to unquote json data': '#/components/schemas/UnableToUnquoteJsonData' + 'Invalid object message': '#/components/schemas/InvalidObjectMessage' + 'Invalid object id': '#/components/schemas/InvalidObjectId' + 'Client specified message Id invalid': '#/components/schemas/ClientSpecifiedMessageIdInvalid' + 'Objects limit exceeded': '#/components/schemas/ObjectsLimitExceeded' + 'Operation path not processable': '#/components/schemas/OperationPathNotProcessable' + FailureDecodingRequestBody: + type: object + additionalProperties: false + properties: + message: + type: string + description: The error message. + example: "Failure decoding request body." + code: + type: integer + description: The HTTP status code returned. + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92001 + href: + type: string + description: The URL to documentation about the error code. + example: "https://help.ably.io/error/92001" + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + FailedToUnmarshalOperationData: + type: object + additionalProperties: false + properties: + message: + type: string + description: The error message. + example: "Failed to unmarshal operation data." + code: + type: integer + description: The HTTP status code returned. + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92002 + href: + type: string + description: The URL to documentation about the error code. + example: "https://help.ably.io/error/92002" + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + InvalidObjectMessage: + type: object + properties: + message: + type: string + example: "Invalid object message." + code: + type: integer + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92000 + href: + type: string + description: The URL to documentation about the error code. + example: https://help.ably.io/error/92000 + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + InvalidObjectId: + type: object + properties: + message: + type: string + example: "Invalid object ID." + code: + type: integer + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92000 + href: + type: string + description: The URL to documentation about the error code. + example: https://help.ably.io/error/92000 + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + InvalidTokenKey: + description: Token expired or revoked, or key revoked, expired, or removed. + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "Token expired" + MalformedCredential: + description: Malformed credential or invalid request. + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "Malformed credential or unknown operation type." Map: type: object properties: @@ -468,17 +888,361 @@ components: - properties: objectId: $ref: '#/components/schemas/ObjectId' - Counter: + NoObjectsMatchedPath: + type: object + additionalProperties: false + properties: + message: + type: string + description: The error message. + example: "(92005) No objects matched path." + code: + type: integer + description: The HTTP status code returned. + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92005 + href: + type: string + description: The URL to documentation about the error code. + example: "https://help.ably.io/error/92005" + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + NoOperationsInRequest: + type: object + additionalProperties: false + properties: + message: + type: string + description: The error message. + example: "No operations in request." + code: + type: integer + description: The HTTP status code returned. + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92003 + href: + type: string + description: The URL to documentation about the error code. + example: "https://help.ably.io/error/92003" + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + ObjectId: + type: string + example: "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000" + ObjectIdOrPathRequired: + type: object + additionalProperties: false + properties: + message: + type: string + description: The error message. + example: "(92006) Object ID or path required." + code: + type: integer + description: The HTTP status code returned. + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92006 + href: + type: string + description: The URL to documentation about the error code. + example: "https://help.ably.io/error/92006" + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + ObjectNotFound: + title: Object not found + type: object + properties: + message: + type: string + description: The error message. + example: "Object not found." + code: + type: integer + description: The HTTP status code returned. + example: 404 + statusCode: + type: integer + description: The Ably error code. + example: 92004 + href: + type: string + description: The URL to documentation about the error code. + example: "https://help.ably.io/error/92004" + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + ObjectsLimitExceeded: type: object + properties: + message: + type: string + example: "Objects limit max number exceeded." + code: + type: integer + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 32001 + href: + type: string + description: The URL to documentation about the error code. + example: https://help.ably.io/error/32001 + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + ObjectWithDetails: + type: object + required: + - objectId properties: objectId: $ref: '#/components/schemas/ObjectId' - data: + map: + $ref: '#/components/schemas/Map' + counter: + $ref: '#/components/schemas/Counter' + OperationPathNotProcessable: + type: object + properties: + message: + type: string + example: "Operation path not processable." + code: + type: integer + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92007 + href: + type: string + description: The URL to documentation about the error code. + example: https://help.ably.io/error/92007 + details: type: object - properties: - number: - type: number - example: 5 + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + OperationObjectSubscribeUnauthorized: + title: Operation object-subscribe unauthorized on channel. + type: object + properties: + message: + type: string + example: "Operation object-subscribe unauthorized on channel." + code: + type: integer + example: 401 + statusCode: + type: integer + description: The Ably error code. + example: 40160 + href: + type: string + description: The URL to documentation about the error code. + example: https://help.ably.io/error/40160 + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + RateLimitExceeded: + description: Rate limit exceeded. + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 429 + message: + type: string + example: "Rate limit exceeded." + StateOperationsOnlyAppliedOnRegularChannels: + title: State operations only applied on regular channels + type: object + properties: + message: + type: string + description: The error message. + example: "State operations only applied on regular channels (e.g., not [meta], [chat], etc channels)." + code: + type: integer + description: The HTTP status code returned. + example: 403 + statusCode: + type: integer + description: The Ably error code. + nullable: true + href: + type: string + description: The URL to documentation about the error code. + nullable: true + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + TombstoneObjectError: + type: object + properties: + message: + type: string + description: The error message. + example: "Unable to fetch objects tree for tombstone object." + code: + type: integer + description: The HTTP status code returned. + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92003 + href: + type: string + description: The URL to documentation about the error code. + example: "https://help.ably.io/error/92003" + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + UnknownOperationType: + type: object + additionalProperties: false + properties: + message: + type: string + description: The error message. + example: "Unknown operation type." + code: + type: integer + description: The HTTP status code returned. + example: 400 + statusCode: + type: integer + description: The Ably error code. + example: 92007 + href: + type: string + description: The URL to documentation about the error code. + example: "https://help.ably.io/error/92007" + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href + UnableToUnquoteJsonData: + type: object + properties: + message: + type: string + example: "Unable to unquote JSON encoded data field." + code: + type: integer + example: 400 + statusCode: + type: integer + description: The Ably error code. + nullable: true + href: + type: string + description: The URL to documentation about the error code. + nullable: true + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + error: + type: object + additionalProperties: false + properties: + message: + type: string + description: The error message. + code: + type: integer + description: The HTTP status code returned. + statusCode: + type: integer + description: The Ably error code. + href: + type: string + description: The URL to documentation about the error code. + details: + type: object + nullable: true + description: Any additional details about the error message. + required: + - message + - code + - statusCode + - href parameters: idempotentParameter: name: id From 263f80a8091af38fd8e28939be9e787281b57988 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 29 Apr 2025 11:09:46 +0100 Subject: [PATCH 27/38] fixup! Update Liveobjects REST API to have http responses --- static/open-specs/liveobjects.yaml | 301 +++++++---------------------- 1 file changed, 70 insertions(+), 231 deletions(-) diff --git a/static/open-specs/liveobjects.yaml b/static/open-specs/liveobjects.yaml index 712b753079..17e30d6d72 100644 --- a/static/open-specs/liveobjects.yaml +++ b/static/open-specs/liveobjects.yaml @@ -6,7 +6,7 @@ info: # LiveObjects API LiveObjects provides a set of purpose-built APIs and data structures to handle the complexities of persisting and synchronizing state, freeing you to focus on building features instead of managing concurrency or conflict resolution. - +

LiveObjects enables you to store data as "objects" on a channel. These objects are automatically synchronized in realtime across all connected clients, and any conflicts that arise from concurrent updates are seamlessly resolved in the background. servers: - url: https://main.realtime.ably.net/ @@ -19,22 +19,11 @@ paths: description: | Fetches the list of objects stored on the channel.

- In the list API, data values represent a concrete piece of data (a number, a string, etc) or a reference to another object. The key in the data value indicates the type that you can expect to receive in the value.

+ In the list API, data values represent a concrete piece of data (a number, a string, etc) or a reference to another object. The key in the data value indicates the type that you can expect to receive in the value, for example, `{ "data": { "string" : "Ably Pub/Sub" }}`.

- ```json - { "data": { "number" : 4 }} - { "data": { "string" : "Ably Pub/Sub" }} - { "data": { "boolean" : true }} - { "data": { "bytes": "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=", "encoding": "base64" }} - { "data": { - "objectId": - "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" - }} - ``` + Maps are made of entries, which are a user-defined key (e.g. `myMapKey`) and a data value. Counters are data values containing a number.

-
Maps are made of entries, which are a user-defined key (e.g. `myMapKey`) and a data value. Counters are data values containing a number. - tags: - - List objects + For more information on objects, see the Ably LiveObjects documentation on [objects](/docs/liveobjects/concepts/objects). parameters: - name: channelId in: path @@ -142,9 +131,9 @@ paths: post: summary: Create or update objects. description: | - Allows creating or updating objects on the channel by providing the operation and data in the request body. If `objectId` is not provided, the server will generate one and return it in the response. - tags: - - Create or update objects + Allows creating or updating objects on the channel by providing the operation and data in the request body. If `objectId` is not provided, the server will generate one and return it in the response.

+ + For more information on operations, see the Ably LiveObjects documentation on [operations](/docs/liveobjects/concepts/operations). parameters: - name: channelId in: path @@ -298,8 +287,6 @@ paths: summary: Gets an object using an object ID. description: | Fetch a LiveObject stored on the channel in a tree structure. - tags: - - Get object by ID parameters: - name: channelId in: path @@ -394,8 +381,6 @@ paths: summary: Get a compact tree of objects description: | Returns a tree structure of the objects in a concise format that's easier to unmarshal into data types that represent your state. To fetch the full object tree, use the objectId root. - tags: - - Get compact object parameters: - name: channelId in: path @@ -461,103 +446,6 @@ paths: description: Too many requests $ref: '#/components/schemas/RateLimitExceeded' -tags: - - name: List objects - description: | - Operations related to listing objects on a channel. - - name: Create or update objects - description: | - Operations related to creating or updating objects on a channel. - - name: Get object by ID - description: | - Operations related to retrieving an object by its ID. - - name: Get compact object - description: | - Operations related to retrieving a compact representation of an object tree. - - name: objectId format - description: | - An `objectId` is made of the following components:

- - ``` - objectType:base64hash@millisecondTimestamp` - // Example: - counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000 - ``` - -
The object types are `map` or `counter`.

- - The millisecond timestamp is "now". There is a small leeway to compensate for server clocks being out of sync. You can fetch the ably server time using `rest.ably.io/time`.

- - The base64 raw url encoding hash is made of `initialValue:nonce`. The initial value is the raw bytes taken from the data field on create operations. The `nonce` is any random string. - - | initial value | nonce | result hash | - |-------------------------|--------|-------------------------------------------------| - | `{"value":3.1415926539}` | `nonce` | `u41d1-DfkEt1AtbyJUSUJn3qAFblVVGmx5Dpg-ToCeI` | - | `{"foo":{"string":"bar"}}` | `nonce` | `ME2yWbb_6sK5AlwxklHA8mTBPxPZx9iyW2Zk6rKJfRs` | - | `{"myMapKey":{"string":"myMapValue"}}` | `myNonce` | `99OSLwCFQsrs6GID4M8rfO_0sYmVwRADcECua_-yXQE` | - - > **Note:** the initial value is space and case sensitive. - - The Operations API accepts the “data” field as either a json object, or as a string with an encoding field. For example, this request passes the data field for a counter as a string with json encoding.

- - ```json - { - "operation": "COUNTER_CREATE", - "data": "{ \"number\": 3.1415926 }", - "encoding": "json" - } - ``` - - name: Object metadata - description: | - Object metadata describes the internal details of an object. There are two metadata fields that are common to all objects: - - | Field | Description | - |-------|-------------| - | Tombstone | true or false, indicates if the object has been deleted. Objects will exist as tombstones for a short while to ensure they remain deleted and are not accidentally created by a lagging LiveObjects client. | - | SiteTimeserials | a map of sites, and the last operation applied to this object from that site. | - - Maps have additional metadata attached to their map entries. - - | Field | Description | - | MapSemantics | Indicates the conflict resolution method used in this map. | - | Timeserial | The last operation applied to this map key, used to preserve the map semantics.| - | Tombstone | true or false, indicates if the map entry has been deleted. Tombstoned map values are not included in the responses by default or when using the @metadata=false@ query param. | - - Example object with metadata included: - - ```json - { - "objectId": "map:YIffJYRAP2k2e7ZP+xzequ9c5kDu1LfI/sEOKoWHvv4@1742479683000", - "map": { - "mapSemantics": "LWW", - "entries": { - "myMapKey": { - "timeserial": "...", - "tombstone": false, - "data": { "string": "my map value" } - }, - "myObjectRef": { - "timeserial": "...", - "tombstone": true, - "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" } - } - } - } - } - ``` - - - name: Tombstones - description: | - Tombstones are used on objects and on map entires to indicate that the object or map entry has been deleted. This protects against lagging LiveObjects clients from re-introducing a deleted value by accident. - - Tombstone objects are not included in API responses by default, but can be enabled using `metadata=true` query param. - - Tombstone map entry keys are included by default, but the values are not. The key will be present in the entries object but with a null value e.g. `myKey: null`. Tombstoned map entry values can be included using the `metadata=true` flag. - - - name: Cyclic and diamond references - description: | - In the List, Get object, and Get compact object APIs objects will only be included in the response once. For cyclic or diamond references, the later references to any object that was already included will have an objectIds only. The reference will be included even if you request the data to be inlined using query params, as objects will only appear in the response once. - components: schemas: ApplicationDisabled: @@ -589,18 +477,26 @@ components: - code AuthError: title: Auth error - description: Authentication error. - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 401 - message: - type: string - example: "Token expired, token revoked, or client restriction not satisfied." + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "Token expired, token revoked, or client restriction not satisfied." + statusCode: + type: integer + description: The Ably error code. + nullable: true + href: + type: string + description: The URL to documentation about the error code. + nullable: true + details: + type: object + nullable: true + description: Any additional details about the error message. AppNotFound: title: App not found type: object @@ -657,17 +553,26 @@ components: - code ClientRestrictionNotSatisfied: description: Client restriction not satisfied. - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 401 - message: - type: string - example: "Client restriction not satisfied." + type: object + properties: + message: + type: string + example: "Client restriction not satisfied." + code: + type: integer + example: 401 + statusCode: + type: integer + description: The Ably error code. + example: 40160 + href: + type: string + description: The URL to documentation about the error code. + example: https://help.ably.io/error/40160 + details: + type: object + nullable: true + description: Any additional details about the error message. ClientSpecifiedMessageIdInvalid: type: object properties: @@ -845,31 +750,25 @@ components: - statusCode - href InvalidTokenKey: - description: Token expired or revoked, or key revoked, expired, or removed. - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 401 - message: - type: string - example: "Token expired" + title: Token expired or revoked, or key revoked, expired, or removed. + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "Token expired" MalformedCredential: - description: Malformed credential or invalid request. - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 401 - message: - type: string - example: "Malformed credential or unknown operation type." + title: Malformed credential or invalid request. + type: object + properties: + code: + type: integer + example: 401 + message: + type: string + example: "Malformed credential or unknown operation type." Map: type: object properties: @@ -1251,33 +1150,7 @@ components: description: | Optional: Only use when requiring idempotent publishing. All operations support an id field. Operations are deduplicated using idempotent publishing based on the operation's `id` field.

- ```json - { - "id": "myIdempotencyKey", - "operation": "MAP_SET" - "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", - "data": {"key": "isActive", "value": { "boolean": true }} - } - ``` - -
Batches of operations can be made idempotent using a compound key based on the format `:`. The index is the order of the operation in the batch. The base of the `id` must be the same across all operations in the batch.

- - ```json - [ - { - "id": "myIdempotencyKey:0", - "operation": "MAP_SET" - "objectId": "map:cwhvmsq21tXtFDS02TQqPdIhGGezcSc8UsBYeUGygng@1636022994797", - "data": {"key": "isActive", "value": { "boolean": true }} - }, - { - "id": "myIdempotencyKey:1", - "operation": "COUNTER_INC" - "objectId": "counter:DXr2i8FHRGkLrHPccWhXKDj1VUX2s7ACvmTrNEguJXo@1742481614000", - "data": {"number": 1} - } - ] - ``` + Batches of operations can be made idempotent using a compound key based on the format `:`. The index is the order of the operation in the batch. The base of the `id` must be the same across all operations in the batch. pathParameter: name: path in: query @@ -1287,35 +1160,9 @@ components: Paths must start with a key in the 'root' map, and then reference keys in nested maps, until the target location.

- For example, increment the likes counter in the reactions map by 3:

- - ```json - { - "path": "reactions.likes", - "operation": "COUNTER_INC", - "data": { "number": 3 } - } - ``` - -
You can issue a single operation against multiple objects at once using the wildcard `*`. For example, increment all reactions counters by 1.

+ You can issue a single operation against multiple objects at once using the wildcard `*`.

- ```json - { - "path": "reactions.*", - "operation": "COUNTER_INC", - "data": { "number": 1 } - } - ``` - -
Wildcards can be included at the end or in the middle of paths and will match exactly one level in the object tree. For example, this path increments all objects reachable from the root map that have a likes counter:

- - ```json - { - "path": "*.likes", - "operation": "COUNTER_INC", - "data": { "number": 1 } - } - ``` + Wildcards can be included at the end or in the middle of paths and will match exactly one level in the object tree. schema: type: string example: "reactions.likes" @@ -1344,17 +1191,9 @@ components: in: query required: true description: | - The data to be created or updated. The structure depends on the operation being performed. Examples include:

- - ```json - { "data": { "number": 4 }} - { "data": { "string": "Ably Pub/Sub" }} - { "data": { "boolean": true }} - { "data": { "bytes": "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=", "encoding": "base64" }} - { "data": { "objectId": "counter:Nz1ZiNjqsDfkDjA61xarinqpWsqEGAAw2mzWWtvX2b8@1742481614000" }} - ``` + The data to be created or updated. The structure depends on the operation being performed. For example, `{ "data": { "number": 4 }}`.

-
Maps are made of entries, which are a user-defined key (e.g., `myMapKey`) and a data value. Counters are data values containing a number. + Maps are made of entries, which are a user-defined key (e.g., `myMapKey`) and a data value. Counters are data values containing a number. schema: type: object additionalProperties: From 25d63cac1fcec1b88c1da2b17e75bbb71f8e1852 Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Tue, 22 Apr 2025 15:14:20 +0100 Subject: [PATCH 28/38] nav/liveobjects: object types section Rename LiveObjects Features to Object Types, since each menu item corresponds to a LiveObjects data type. --- src/data/nav/liveobjects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index 37881a84a8..58788bbb23 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -27,7 +27,7 @@ export default { ], }, { - name: 'LiveObjects features', + name: 'Object Types', pages: [ { name: 'LiveCounter', From 543543f2f9f16bcaad5878a48764a9254a453536 Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Tue, 22 Apr 2025 15:15:48 +0100 Subject: [PATCH 29/38] nav/liveobjects: rename to getting started --- src/data/nav/liveobjects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index 58788bbb23..c23e870030 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -18,7 +18,7 @@ export default { ], }, { - name: 'Get started', + name: 'Getting started', pages: [ { name: 'Quickstart', From a8ddbdb747dc70f9e824222a3a395201f7aa49fc Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Wed, 23 Apr 2025 12:26:38 +0100 Subject: [PATCH 30/38] liveobjects: add objects concepts docs --- content/liveobjects/concepts/objects.textile | 254 +++++++++++++++++++ content/liveobjects/map.textile | 2 +- src/data/nav/liveobjects.ts | 9 + 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 content/liveobjects/concepts/objects.textile diff --git a/content/liveobjects/concepts/objects.textile b/content/liveobjects/concepts/objects.textile new file mode 100644 index 0000000000..13bb00eeae --- /dev/null +++ b/content/liveobjects/concepts/objects.textile @@ -0,0 +1,254 @@ +--- +title: Objects +meta_description: "Learn how data is represented as objects in Ably LiveObjects" +product: liveobjects +languages: + - javascript +--- + +LiveObjects enables you to store shared data as "objects" on a channel, allowing your application data to be synchronized across multiple users and devices in realtime. This document explains the key concepts you need to know when working with objects. + +h2(#object-types). Object Types + +LiveObjects provides specialized object types to model your application state. These object types are designed to be conflict-free and eventually consistent, meaning that all operations on them are commutative and converge to the same state across all clients. + +h3(#livemap). LiveMap Object + +"LiveMap":/docs/liveobjects/map is a key/value data structure similar to a dictionary or JavaScript "Map":https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map : + +* Keys must be strings +* Values can be primitive types or "references":#composability to other objects +* Supports @set@ and @remove@ operations +* Concurrent updates to the same key are resolved using last-write-wins (LWW) semantics + +blang[javascript]. + + ```[javascript] + // Create a LiveMap + const userSettings = await channel.objects.createMap(); + + // Set primitive values + await userSettings.set('theme', 'dark'); + await userSettings.set('notifications', true); + ``` + +h4(#primitive-types). Primitive Types + +"LiveMap":/docs/liveobjects/map supports the following primitive types as values: + +* @string@ +* @number@ +* @boolean@ +* @bytes@ + + + +h3(#livecounter). LiveCounter Object + +"LiveCounter":/docs/liveobjects/counter is a numeric counter type: + +* The value is a double-precision floating-point number +* Supports @increment@ and @decrement@ operations + +blang[javascript]. + + ```[javascript] + // Create a LiveCounter + const visitsCounter = await channel.objects.createCounter(); + + // Increment the counter + await visitsCounter.increment(1); + ``` + +h3(#root-object). Root Object + +The root object is a special @LiveMap@ instance which: + +* Implicitly exists on a channel and does not need to be created explicitly +* Has the special "objectId":#object-ids of @root@ +* Cannot be deleted +* Serves as the "entry point":#reachability for accessing all other objects on a channel + +Access the root object using the @getRoot()@ function: + +blang[javascript]. + + ```[javascript] + // Get the Root Object + const root = await channel.objects.getRoot(); + + // Use it like any other LiveMap + await root.set('app-version', '1.0.0'); + ``` + +h2(#reachability). Reachability + +All objects must be reachable from the root object (directly or indirectly). Objects that cannot be reached from the root object will eventually "be deleted":/docs/liveobjects/lifecycle#objects-deleted . + +When an object has been deleted, it is no longer usable and calling any methods on the object will fail. + +In the example below, the only reference to the @counterOld@ object is replaced on the @root@. This makes @counterOld@ unreachable and it will eventually be deleted. + +blang[javascript]. + + ```[javascript] + // Create a counter and reference it from the root + const counterOld = await channel.objects.createCounter(); + await root.set('myCounter', counterOld); + + // counterOld will eventually be deleted + counterOld.on('deleted', () => { + console.log('counterOld has been deleted and can no longer be used'); + }); + + // Create a new counter and replace the old one referenced from the root + const counterNew = await channel.objects.createCounter(); + await root.set('myCounter', counterNew); + ``` + + + +h2(#composability). Composability + +LiveObjects enables you to build complex, hierarchical data structures through composability. + +Specifically, a LiveMap:/docs/liveobjects/map can store references to other @LiveMap@ or @LiveCounter@ object instances as values. This allows you to create nested hierarchies of data. + +blang[javascript]. + + ```[javascript] + // Create LiveObjects + const profileMap = await channel.objects.createMap(); + const preferencesMap = await channel.objects.createMap(); + const activityCounter = await channel.objects.createCounter(); + + // Build a composite structure + await preferencesMap.set('theme', 'dark'); + await profileMap.set('preferences', preferencesMap); + await profileMap.set('activity', activityCounter); + await root.set('profile', profileMap); + + // Resulting structure: + // root (LiveMap) + // └── profile (LiveMap) + // ├── preferences (LiveMap) + // │ └── theme: "dark" (string) + // └── activity (LiveCounter) + ``` + + + +It is possible for the same object instance to be accessed from multiple places in your object tree: + +blang[javascript]. + + ```[javascript] + // Create a counter + const counter = await channel.objects.createCounter(); + + // Create two different maps + const mapA = await channel.objects.createMap(); + const mapB = await channel.objects.createMap(); + await root.set('a', mapA); + await root.set('b', mapB); + + // Reference the same counter from both maps + await mapA.set('count', counter); + await mapB.set('count', counter); + + // The counter referenced from each location shows the same + // value, since they refer to the same underlying counter + mapA.get('count').subscribe(() => { + console.log(mapA.get('count').value()); // 1 + }); + mapB.get('count').subscribe(() => { + console.log(mapB.get('count').value()); // 1 + }); + + // Increment the counter + await counter.increment(1); + ``` + +It is also possible that object references form a cycle: + +blang[javascript]. + + ```[javascript] + // Create two different maps + const mapA = await channel.objects.createMap(); + const mapB = await channel.objects.createMap(); + + // Set up a circular reference + await mapA.set('ref', mapB); + await mapB.set('ref', mapA); + + // Add one map to root (both are now reachable) + await root.set('a', mapA); + + // We can traverse the cycle + root.get('a') // mapA + .get('ref') // mapB + .get('ref'); // mapA + ``` + + +h2(#metadata). Metadata + +Objects include metadata that helps with synchronization, conflict resolution and managing the object lifecycle. + + + +h3(#object-ids). Object IDs + +Every object has a unique identifier that distinguishes it from all other objects. + +Object IDs follow a specific format: + +bc[text]. type:hash@timestamp + +For example: + +bc[text]. counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000 + +This format has been specifically designed to ensure uniqueness in a globally distributed system and includes: + +* **type**: the object type (either @map@ or @counter@) +* **hash**: a base64 string encoded hash derived from the initial value of the object and a random nonce +* **timestamp**: a Unix millisecond timestamp denoting the creation time of the object + + + +h3(#tombstones). Tombstones + +Tombstones are markers indicating an object or map entry has been deleted. + +* A tombstone is created for an object when it becomes "unreachable":/docs/liveobjects/concepts/objects#reachability from the root object. +* A tombstone is created for a map entry when it is "removed":/docs/liveobjects/map#remove + +Tombstones protect against lagging clients from re-introducing a deleted value, ensuring all clients eventually converge on the same state. They are eventually garbage collected after a safe period of time. + +h3(#timeserials). Timeserials + +When an operation message is published it is assigned a unique logical timestamp called a "timeserial". + +This timeserial is stored on map entries in order to implement last-write-wins conflict resolution semantics. + +Additionally, all objects store the timeserial of the last operation that was applied to the object. Since Ably operates fully independent data centers, these timeserials are stored on a per-site basis. + +Timeserial metadata is used for internal purposes and is not directly exposed in client libraries. However, it can be viewed using the "REST API":/docs/liveobjects/rest-api-usage. diff --git a/content/liveobjects/map.textile b/content/liveobjects/map.textile index 4f5e9b2745..94625e6489 100644 --- a/content/liveobjects/map.textile +++ b/content/liveobjects/map.textile @@ -42,7 +42,7 @@ blang[javascript]. await channel.objects.createMap({ nestedMap: map }); ``` -h2(#value). Get value for a key +h2(#get). Get value for a key Get the current value for a key in a map using the @LiveMap.get()@ method: diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index c23e870030..579ffc809d 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -26,6 +26,15 @@ export default { }, ], }, + { + name: 'Concepts', + pages: [ + { + name: 'Objects', + link: '/docs/liveobjects/concepts/objects', + }, + ], + }, { name: 'Object Types', pages: [ From 6dddcc860256356c758e237b3244348b02d0480b Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Wed, 23 Apr 2025 14:33:56 +0100 Subject: [PATCH 31/38] liveobjects: add operations concepts docs --- .../liveobjects/concepts/operations.textile | 129 ++++++++++++++++++ src/data/nav/liveobjects.ts | 4 + 2 files changed, 133 insertions(+) create mode 100644 content/liveobjects/concepts/operations.textile diff --git a/content/liveobjects/concepts/operations.textile b/content/liveobjects/concepts/operations.textile new file mode 100644 index 0000000000..d4e640d276 --- /dev/null +++ b/content/liveobjects/concepts/operations.textile @@ -0,0 +1,129 @@ +--- +title: Operations +meta_description: "Learn how objects are updated by operations in Ably LiveObjects." +product: liveobjects +languages: + - javascript +--- + +LiveObjects operations define how object data is updated and synchronized across multiple clients. This document explains the key concepts you need to know when working with operations. + +h2(#operation-types). Operation Types + +Each object type supports specific operations that modify the object's data. + +h3(#livemap). LiveMap Operations + +"LiveMap":/docs/liveobjects/map supports the following operations: + +* @set@: Set a value for a key +* @remove@: Remove a key and its value + +The value of an entry in a @LiveMap@ instance can be a "primitive type":/docs/liveobjects/concepts/objects#primitive-types or a "reference":/docs/liveobjects/concepts/objects#composability to another object. + +blang[javascript]. + + ```[javascript] + // Set a value for a key + await map.set('username', 'alice'); + + // Remove a key + await map.remove('username'); + ``` + +h3(#livecounter). LiveCounter Operations + +"LiveCounter":/docs/liveobjects/counter supports the following operations: + +* @increment@: Increment the counter by a specified amount +* @decrement@: Decrement the counter by a specified amount + +The amount is a double-precision floating-point number, which is the same as underlying type of a "LiveCounter":/docs/liveobjects/concepts/objects#livecounter value. + + + +blang[javascript]. + + ```[javascript] + // Increment counter by 5 + await counter.increment(5); + + // Decrement counter by 2 + await counter.decrement(2); + ``` + +h3(#create-operations). Create Operations + +Create operations are used to instantiate new objects of a given type. + +A create operation can optionally specify an initial value for the object. + +blang[javascript]. + + ```[javascript] + // Create a map with initial values + const userMap = await channel.objects.createMap({ + username: 'alice', + status: 'online' + }); + + // Create a counter with initial value + const scoreCounter = await channel.objects.createCounter(100); + ``` + +When a create operation is processed, an "object ID":/docs/liveobjects/concepts/objects#object-ids for the new object instance is automatically generated for the object. + + + +h2(#object-ids). Object IDs + +Every operation is expressed relative to a specific object instance, identified by its "object ID":/docs/liveobjects/concepts/objects#object-ids, which determines which object the operation is applied to. + +When using a client library object IDs are handled automatically, allowing you work directly with object references: + +blang[javascript]. + + ```[javascript] + // The published operation targets the object ID of the `userMap` object instance + await userMap.set('username', 'alice'); + ``` + +Therefore it is important that you obtain an up-to-date object instance before performing operations on an object. For example, you can "subscribe":/docs/liveobjects/map#subscribe-data to a @LiveMap@ instance to ensure you always have an up-to-date reference to any child objects in the map: + +blang[javascript]. + + ```[javascript] + const root = await channel.objects.getRoot(); + + let myCounter = root.get('myCounter'); + root.subscribe(() => { myCounter = root.get('myCounter'); }); + + // before incrementing, ensure we have an up-to-date object reference if + // the counter instance at the 'myCounter' key in the root map changes + await myCounter.increment(1); + ``` + +In the "REST API":/docs/liveobjects/rest-api-usage#updating-objects-by-id , this relationship is made explicit: + +bc[sh]. curl -X POST https://rest.ably.io/channels/my-channel/objects \ + -u "{{API_KEY}}" + -H "Content-Type: application/json" \ + --data \ +'{ + "operation": "MAP_SET", + "objectId": "root", + "data": {"key": "username", "value": {"string": "alice"}} +}' + +h2(#batch-operations). Batch Operations + +"Batch operations":/docs/liveobjects/batch can be used to batch a set of operations together: + +* Multiple operations are grouped into a single atomic unit +* All operations in the batch either succeed together or fail together +* Operations in a batch are sent as a single message +* No operations from other clients can be interleaved within a batch diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index 579ffc809d..a08e0c43c4 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -33,6 +33,10 @@ export default { name: 'Objects', link: '/docs/liveobjects/concepts/objects', }, + { + name: 'Operations', + link: '/docs/liveobjects/concepts/operations', + }, ], }, { From 2d4e91e922bdd05623cb90b2217a0860eb5570e6 Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Thu, 24 Apr 2025 10:13:48 +0100 Subject: [PATCH 32/38] liveobjects: rest api usage --- content/liveobjects/rest-api-usage.textile | 925 +++++++++++++++++++++ src/data/nav/liveobjects.ts | 4 + 2 files changed, 929 insertions(+) create mode 100644 content/liveobjects/rest-api-usage.textile diff --git a/content/liveobjects/rest-api-usage.textile b/content/liveobjects/rest-api-usage.textile new file mode 100644 index 0000000000..51cb1be867 --- /dev/null +++ b/content/liveobjects/rest-api-usage.textile @@ -0,0 +1,925 @@ +--- +title: Using the REST API +meta_description: "Learn how to work with Ably LiveObjects using the REST API" +product: liveobjects +--- + +LiveObjects provides a comprehensive REST API that allows you to directly work with objects without using a client SDK. + +h2(#authentication). Authentication + +View the REST API "authentication":/docs/api/rest-api#authentication documentation for details on how to authenticate your requests. + +To use LiveObjects, an API key must have at least the @object-subscribe@ capability. With only this capability, clients will have read-only access, preventing them from publishing operations. + +In order to create or update objects, make sure your API key includes both @object-subscribe@ and @object-publish@ "capabilities":/docs/auth/capabilities to allow full read and write access. + +h2(#data-values). Data values + +When working with objects via the REST API, "primitive types":/docs/liveobjects/concepts/objects#primitive-types and "object references":/docs/liveobjects/concepts/objects#composability are included in request and response bodies under @data@ fields. + +The key in the @data@ object indicates the type of the value: + +```[json] +{ "data": { "number" : 42 }} +{ "data": { "string" : "LiveObjects is awesome" }} +{ "data": { "boolean" : true }} +{ "data": { "bytes": "TGl2ZU9iamVjdHMgaXMgYXdlc29tZQo=" }} +{ "data": { "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669" }} +``` + + + +h2(#fetching-objects). Fetching objects + +h3(#fetching-objects-list). List objects + +h6. GET rest.ably.io/channels/@@/objects + +Fetch a flat list of objects on the channel: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +The response includes the IDs of the objects on the channel: + +```[json] +[ + "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669" + "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "root", +] +``` + +h4(#fetching-objects-list-values). Including values + +To include values of the objects in the response, set the @values=true@ query parameter: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects?values=true" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +```[json] +[ + { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669", + "counter": { + "data": { + "number": 10 + } + } + }, + { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "counter": { + "data": { + "number": 5 + } + } + }, + { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "map": { + "entries": { + "down": { + "data": { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669" + } + }, + "up": { + "data": { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269" + } + } + } + } + }, + { + "objectId": "root", + "map": { + "entries": { + "votes": { + "data": { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519" + } + } + } + } + } +] +``` + +h4(#fetching-objects-list-metadata). Including metadata + +You can optionally include additional object "metadata":/docs/liveobjects/concepts/objects#metadata with the @metadata@ query option: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects?values=true&metadata=true" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +```[json] +[ + { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669", + "counter": { + "data": { + "number": 10 + } + }, + "siteTimeserials": { + "e02": "01745828651671-000@e025VxXLABoR0C19591332:000" + }, + "tombstone": false + }, + { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "counter": { + "data": { + "number": 5 + } + }, + "siteTimeserials": { + "e02": "01745828645271-000@e025VxXLABoR0C19591332:000" + }, + "tombstone": false + }, + { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "map": { + "mapSemantics": "LWW", + "entries": { + "down": { + "timeserial": "01745828651671-000@e025VxXLABoR0C19591332:001", + "tombstone": false, + "data": { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669" + } + }, + "up": { + "timeserial": "01745828645271-000@e025VxXLABoR0C19591332:001", + "tombstone": false, + "data": { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269" + } + } + } + }, + "siteTimeserials": { + "e02": "01745828651671-000@e025VxXLABoR0C19591332:001" + }, + "tombstone": false + }, + { + "objectId": "root", + "map": { + "mapSemantics": "LWW", + "entries": { + "votes": { + "timeserial": "01745828596522-000@e025VxXLABoR0C19591332:001", + "tombstone": false, + "data": { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519" + } + } + } + }, + "siteTimeserials": { + "e02": "01745828596522-000@e025VxXLABoR0C19591332:001" + }, + "tombstone": false + } +] +``` + + + +h4(#fetching-objects-list-pagination). Pagination + +The response can be "paginated":/docs/api/rest-api#pagination with @cursor@ and @limit@ query params using relative links. + +Use the @limit@ query parameter to specify the maximum number of objects to include in the response: + +```[sh] + curl -v -X GET "https://rest.ably.io/channels/my-channel/objects?values=true&limit=2" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +```[json] +[ + { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669", + "counter": { + "data": { + "number": 10 + } + } + }, + { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "counter": { + "data": { + "number": 5 + } + } + } +] +``` + +The response includes @Link@ headers which provide relative links to the first, current and next pages of the response: + +``` +link: ; rel="first" +link: ; rel="current" +link: ; rel="next" +``` + +The list objects endpoints returns objects ordered lexicographically by object ID. The object ID of the first object in the next page is used as the @cursor@ value for the next request: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects?cursor=map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519&limit=2&values=true" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +```[json] +[ + { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "map": { + "entries": { + "down": { + "data": { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669" + } + }, + "up": { + "data": { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269" + } + } + } + } + }, + { + "objectId": "root", + "map": { + "entries": { + "votes": { + "data": { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519" + } + } + } + } + } +] +``` + +h3(#fetching-objects-get). Get objects + +h6. GET rest.ably.io/channels/@@/objects/@@ + +h4(#fetching-objects-get-single). Get a single object + +To fetch a single object on the channel, specify the object ID in the URL path: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects/root" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +The response contains a single object referencing any nested child objects by their object ID: + +```[json] +{ + "objectId": "root", + "map": { + "entries": { + "votes": { + "data": { "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519" } + } + } + } +} +``` + +h4(#fetching-objects-get-children). Get an object and its children + +To fetch the objects on the channel in a tree structure use the @children@ query parameter: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects/root?children=true" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +The response includes the object tree starting from the specified object ID. Nested child objects are resolved and their values are included in the response: + +```[json] +{ + "objectId": "root", + "map": { + "entries": { + "votes": { + "data": { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "map": { + "entries": { + "down": { + "data": { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669", + "counter": { + "data": { + "number": 10 + } + } + } + }, + "up": { + "data": { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "counter": { + "data": { + "number": 5 + } + } + } + } + } + } + } + } + } + } +} +``` + +Use @root@ as the object ID in the URL to get the full object tree, or any other object ID to fetch a subset of the tree using that object as the entrypoint. + +h4(#fetching-objects-get-metadata). Including metadata + +You can optionally include additional object "metadata":/docs/liveobjects/concepts/objects#metadata for all objects included in the response with the @metadata@ query option: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects/root?children=true&metadata=true" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +```[json] +{ + "objectId": "root", + "map": { + "mapSemantics": "LWW", + "entries": { + "votes": { + "timeserial": "01745828596522-000@e025VxXLABoR0C19591332:001", + "tombstone": false, + "data": { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "map": { + "mapSemantics": "LWW", + "entries": { + "down": { + "timeserial": "01745828651671-000@e025VxXLABoR0C19591332:001", + "tombstone": false, + "data": { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669", + "counter": { + "data": { + "number": 10 + } + }, + "siteTimeserials": { + "e02": "01745828651671-000@e025VxXLABoR0C19591332:000" + }, + "tombstone": false + } + }, + "up": { + "timeserial": "01745828645271-000@e025VxXLABoR0C19591332:001", + "tombstone": false, + "data": { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "counter": { + "data": { + "number": 5 + } + }, + "siteTimeserials": { + "e02": "01745828645271-000@e025VxXLABoR0C19591332:000" + }, + "tombstone": false + } + } + } + }, + "siteTimeserials": { + "e02": "01745828651671-000@e025VxXLABoR0C19591332:001" + }, + "tombstone": false + } + } + } + }, + "siteTimeserials": { + "e02": "01745828596522-000@e025VxXLABoR0C19591332:001" + }, + "tombstone": false +} +``` + +h4(#fetching-objects-get-pagination). Pagination + +The tree-structured response can be paginated using the @limit@ parameter to specify the maximum number of objects to include in the response. If a nested child object exists which cannot be included in the response because the limit has been reached, it will be included by reference to its object ID rather than its value: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects/root?children=true&limit=1" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +```[json] +{ + "objectId": "root", + "map": { + "entries": { + "votes": { + "data": { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519" + } + } + } + } +} +``` + +To obtain the next page, make a subsequent query specifying the referenced object ID as the entrypoint: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects/map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519?children=true&limit=1" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +```[json] +{ + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "map": { + "entries": { + "down": { + "data": { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669" + } + }, + "up": { + "data": { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269" + } + } + } + } +} +``` + +h4(#fetching-objects-get-cycles). Cyclic references + +When using the @children@ query parameter, cyclic references in the object tree will be included as a reference to the object ID rather than including the same object in the response more than once. + +For example, if we created a cycle in the object tree by adding a reference to the root object in the @votes@ @LiveMap@ instance with the following operation: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "MAP_SET", + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "data": {"key": "myRoot", "value": {"objectId": "root"}} + }' +``` + +The response will handle the cyclic reference by including the @myRoot@ key in the response as a reference to the object ID of the root object: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects/root?children=true" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +```[json] +{ + "objectId": "root", + "map": { + "entries": { + "votes": { + "data": { + "objectId": "map:ja7cjMUib2LmJKTRdoGAG9pbBYCnkMObAVpmojCOmek@1745828596519", + "map": { + "entries": { + "down": { + "data": { + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669", + "counter": { + "data": { + "number": 10 + } + } + } + }, + "up": { + "data": { + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "counter": { + "data": { + "number": 5 + } + } + } + }, + "myRoot": { + "data": { + "objectId": "root" + } + } + } + } + } + } + } + } +} +``` + +h3(#fetching-objects-compact). Get a compact view of objects + +h6. GET rest.ably.io/channels/@@/objects/@@/compact + +To fetch the objects on the channel in a tree structure in a concise format: + +```[sh] + curl -X GET "https://rest.ably.io/channels/my-channel/objects/root/compact" \ + -u {{API_KEY}} + -H "Content-Type: application/json" +``` + +The response includes a compact representation of the object tree that is easy to read: + +```[json] +{ + "votes": { + "up": 5, + "down": 10 + } +} +``` + +When using the compact format: + +* @LiveMap@ instances will be represented as a JSON representation of its entries +* @LiveCounter@ instances will be represented as its numeric value + +"Cyclic references":#fetching-objects-get-cycles are handled in the same way as for the tree-structured response. In the example below, the @myRoot@ key references the root object, which is already included in the response: + +```[json] +{ + "votes": { + "up": 5, + "down": 10, + "myRoot": { "objectId": "root" } + } +} +``` + +The compact format inlines object ID references under the @objectId@ key, allowing references to other objects to be differentiated from string values. + + + +h2(#updating-objects). Publishing operations + +h6. POST rest.ably.io/channels/@@/objects + +All operations are published to the same endpoint. The request body specifies: + +* The type of operation to publish +* The object(s) to which the operation should be applied +* The operation payload + +The request body is of the form: + +```[json] +{ + "operation": "", + "objectId": "", + "path": "", + "data": "" +} +``` + +The @operationType@ is a string that specifies the type of operation to publish and must be one of: + +* @MAP_CREATE@ +* @MAP_SET@ +* @MAP_REMOVE@ +* @COUNTER_CREATE@ +* @COUNTER_INC@ + + + +Either the @objectId@ or @path@ fields are used to specify the target object(s) for the operation. + +The operation payload is provided in the @data@ in accordance with the specified operation type. + +h3(#updating-objects-by-id). Update a specific object instance + +To perform operations on a specific object instance, you need to provide its object ID in the request body: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "COUNTER_INC", + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "data": {"number":1} + }' +``` + +The response includes the ID of the published operation message, the channel and a list of object IDs that were affected by the operation: + +```[json] +{ + "messageId": "TJPWHhMTrF:0", + "channel": "my-channel", + "objectIds": ["counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269"] +} +``` + +h3(#updating-objects-by-path). Update an object by path + +Path operations provide a convenient way to target objects based on their location in the object tree. + +Paths are expressed relative to the structure of the object as defined by the "compact":#fetching-objects-compact view of the object tree. + +For example, given the following compact view of the object tree: + +The following example increments the @LiveCounter@ instance stored at the @up@ key on the @votes@ @LiveMap@ instance on the root object: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "COUNTER_INC", + "path": "votes.up", + "data": { "number": 1 } + }' +``` + + + +You can use wildcards in paths to target multiple objects at once. To increment all @LiveCounter@ instances in the @votes@ @LiveMap@ instance: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "COUNTER_INC", + "path": "votes.*", + "data": { "number": 1 } + }' +``` + +The response includes the IDs of each of the affected object instances: + +```[json] +{ + "messageId": "0Q1w-LpA11:0", + "channel": "my-channel", + "objectIds": [ + "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669" + ] +} +``` + +Wildcards can be included at the end or in the middle of paths and will match exactly one level in the object tree. For example, given the following compact view of the object tree: + +```[json] +{ + "posts": { + "post1": { + "votes": { + "up": 5, + "down": 10 + } + }, + "post2": { + "votes": { + "up": 5, + "down": 10 + } + } + } +} +``` + +The following example increments the upvote @LiveCounter@ instances for all posts in the @posts@ @LiveMap@ instance: + +```[sh] + curl -X POST "https://sandbox-rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "COUNTER_INC", + "path": "posts.*.votes.up", + "data": { "number": 1 } + }' +``` + +If your @LiveMap@ keys contain periods, you can escape them with a backslash. The following example increments the upvote @LiveCounter@ instance for a post with the key @post.123@: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "COUNTER_INC", + "path": "posts.post\.123.votes.up", + "data": { "number": 1 } + }' +``` + +h3(#creating-objects). Creating objects + +Use the @MAP_CREATE@ and @COUNTER_CREATE@ operations to create new objects. You can optionally specify an initial value for the object in the @data@ field when creating it. + +For @MAP_CREATE@, the @data@ field should be a JSON object that contains the initial entries for the @LiveMap@ instance: + +```[json] +{ + "operation": "MAP_CREATE", + "data": { + "title": {"string": "LiveObjects is awesome"}, + "createdAt": {"number": 1745835181122}, + "isPublished": {"boolean": true} + } +} +``` + +For @COUNTER_CREATE@, the @data@ field should be a JSON object that contains the initial value for the @LiveCounter@ instance: + +```[json] +{ + "operation": "COUNTER_CREATE", + "data": { "number": 5 } +} +``` + +When you create a new object it is important that the new object is assigned to the object tree so that it is "reachable":/docs/liveobjects/concepts/objects#reachability from the root object. + +The simplest way to do this is to use the @path@ field in the request body. The path is relative to the root object and specifies where in the object tree the new object should be created. + +The following example creates a new @LiveMap@ instance and assigns it to the @posts@ @LiveMap@ instance on the root object under the key @post1@: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "MAP_CREATE", + "path": "posts.post1", + "data": { + "title": {"string": "LiveObjects is awesome"}, + "createdAt": {"number": 1745835181122}, + "isPublished": {"boolean": true} + } + }' +``` + +When using the @path@ specifier with a @COUNTER_CREATE@ or @MAP_CREATE@ operation, the server constructs _two_ operations which are published as a "batch":#batch-operations : + +* A @MAP_CREATE@ or @COUNTER_CREATE@ operation used to create the new object +* A @MAP_SET@ operation used to assign the new object to the @LiveMap@ instance specified by the @path@ + +Therefore the response will include the object IDs of all objects affected by the resulting set of operations: + +```[json] +{ + "messageId": "mkfjWU2jju:0", + "channel": "my-channel", + "objectIds": [ + "map:cRCKx-eev7Tl66jGfl1SkZh_uEMo6F5jyV0B7mUn4Zs@1745835549101", + "map:a_oQqPYUGxi95_Cn0pWcsoeBlHZZtVW5xKIw0hnJCZs@1745835547258" + ] +} +``` + +h3(#removing-objects). Removing objects + +There is no explicit delete operation for objects themselves. Objects that are not reachable from the root map will be eligible for garbage collection. + +Remove a reference to a nested object in a @LiveMap@ instance using the @MAP_REMOVE@ operation: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "operation": "MAP_REMOVE", + "objectId": "root", + "data": {"key": "posts"} + }' +``` + +If no other references to the object exist, it will no longer be reachable from the root object and will be eligible for garbage collection. + + + +h3(#batch-operations). Batch operations + +You can group several operations into a single request by sending an array of operations. + +All operations inside the array form a "batch operation":/docs/liveobjects/concepts/operations#batch-operations which is published as a single message. All operations in the batch are processed as a single atomic unit. + +The following example shows how to increment two distinct @LiveCounter@ instances in a single batch operation: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '[ + { + "operation": "COUNTER_INC", + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "data": {"number": 1} + }, + { + "operation": "COUNTER_INC", + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669", + "data": {"number": 1} + } + ]' +``` + +h3(#idempotent-operations). Idempotent operations + +Publish operations idempotently in the same way as for "idempotent message publishing":/docs/api/rest-api#idempotent-publish by specifying a @id@ for the operation message: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '{ + "id": "my-idempotency-key", + "operation": "COUNTER_INC", + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "data": {"number": 1} + }' +``` + +For batch operations, use the format @:@ where the index is the zero-based index of the operation in the array: + +```[sh] + curl -X POST "https://rest.ably.io/channels/my-channel/objects" \ + -u {{API_KEY}} \ + -H "Content-Type: application/json" \ + -d '[ + { + "id": "my-idempotency-key:0", + "operation": "COUNTER_INC", + "objectId": "counter:iVji62_MW_j4dShuJbr2fmsP2D8MyCs6tFqON9-xAkc@1745828645269", + "data": {"number": 1} + }, + { + "id": "my-idempotency-key:1", + "operation": "COUNTER_INC", + "objectId": "counter:JbZYiHnw0ORAyzzLSQahVik31iBDL_ehJNpTEF3qwg8@1745828651669", + "data": {"number": 1} + } + ]' +``` diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index a08e0c43c4..6ea29176e0 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -67,6 +67,10 @@ export default { name: 'Typing', link: '/docs/liveobjects/typing', }, + { + name: 'Using the REST API', + link: '/docs/liveobjects/rest-api-usage', + }, ], }, ], From cb62a2f1b78ff9a69293cdaf52016f8f1b77a1dc Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Mon, 28 Apr 2025 13:23:36 +0100 Subject: [PATCH 33/38] liveobjects: add synchronization concepts docs --- .../concepts/synchronization.textile | 33 +++++++++++++++++++ src/data/nav/liveobjects.ts | 4 +++ 2 files changed, 37 insertions(+) create mode 100644 content/liveobjects/concepts/synchronization.textile diff --git a/content/liveobjects/concepts/synchronization.textile b/content/liveobjects/concepts/synchronization.textile new file mode 100644 index 0000000000..a8ec4c0fe6 --- /dev/null +++ b/content/liveobjects/concepts/synchronization.textile @@ -0,0 +1,33 @@ +--- +title: Synchronization +meta_description: "Learn how data is synchronized between clients." +product: liveobjects +languages: + - javascript +--- + +LiveObjects provides a powerful synchronization mechanism to ensure that all clients see the same data. This document explains how synchronization works in LiveObjects. + +h2(#channel-objects). Channel Objects + +Ably maintains the authoritative state of all objects on each channel across its distributed infrastructure. + +Each object instance is identified by a unique "object ID":/docs/liveobjects/concepts/objects#object-ids. The channel holds the complete up-to-date data of all objects on the channel. + +Ably stores the object data durably such that the data is available even after the channel becomes inactive. The data is stored in multiple regional datacenters and across multiple availability zones. This ensures that the data is available even if there is disruption in one or more datacenters. + +When a channel first becomes active in a region, the channel loads the object data from durable storage into memory to facilitate low-latency operation processing. + +h2(#client-objects). Client Objects + +While Ably maintains the source of truth on the channel, each connected client keeps a local representation of the objects on the channel. + +When the client first attaches to the channel, the state of the channel objects is streamed to the client. "Lifecycle events":/docs/liveobjects/lifecycle#synchronization allow your application to be notified when the local state is being synchronized with the Ably service. + +All object operations published to the channel are broadcast to subscribed clients, which apply the operations to their local client objects when they are received. This allows clients to maintain a consistent view of the channel objects in a bandwidth-efficient way, since only the operations (rather than the updated objects themselves) are sent over the client's connection. + + + +If there is a loss of continuity on the channel for any reason, such as the client becoming disconnected for more than two minutes and entering the "suspended state":/docs/connect/states#connection-states , the client objects will automatically be resynchronized when it reconnects. diff --git a/src/data/nav/liveobjects.ts b/src/data/nav/liveobjects.ts index 6ea29176e0..721fb44193 100644 --- a/src/data/nav/liveobjects.ts +++ b/src/data/nav/liveobjects.ts @@ -37,6 +37,10 @@ export default { name: 'Operations', link: '/docs/liveobjects/concepts/operations', }, + { + name: 'Synchronization', + link: '/docs/liveobjects/concepts/synchronization', + }, ], }, { From faa8cac64afebc601d4fcca6d39062c452e6fdf6 Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Wed, 30 Apr 2025 12:31:10 +0100 Subject: [PATCH 34/38] liveobjects: update links to root object docs Replaces references to /docs/liveobjects/quickstart#step-4 with /docs/liveobjects/concepts/objects#root-object --- content/liveobjects/batch.textile | 2 +- content/liveobjects/counter.textile | 2 +- content/liveobjects/lifecycle.textile | 2 +- content/liveobjects/map.textile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/liveobjects/batch.textile b/content/liveobjects/batch.textile index 649facaf67..f55bfc3764 100644 --- a/content/liveobjects/batch.textile +++ b/content/liveobjects/batch.textile @@ -48,7 +48,7 @@ blang[javascript]. The batch context provides a synchronous API for objects operations inside the batch callback. It mirrors the asynchronous API found on @channel.objects@, including "LiveCounter":/docs/liveobjects/counter and "LiveMap":/docs/liveobjects/map. - To access the batch API, call @BatchContext.getRoot()@, which synchronously returns a wrapper around the "root":/docs/liveobjects/quickstart#step-4 object instance. This wrapper enables you to access and modify objects within a batch. + To access the batch API, call @BatchContext.getRoot()@, which synchronously returns a wrapper around the "root":/docs/liveobjects/concepts/objects#root-object object instance. This wrapper enables you to access and modify objects within a batch.