From 7ab8bacd4b4fdd8189cb4e15570e197809adba76 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 24 Feb 2026 11:59:02 +0000 Subject: [PATCH 01/43] PathObject and Instance API - access and mutation methods (no subscription/batching) --- specifications/features.md | 2 +- specifications/objects-features.md | 193 ++++++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 3 deletions(-) diff --git a/specifications/features.md b/specifications/features.md index 0a7d36933..32f5a0ab7 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -939,7 +939,7 @@ The threading and/or asynchronous model for each realtime library will vary by l ### RealtimeObject {#realtime-objects} -Reserved for `RealtimeObject` feature specification, see [objects-features](../objects-features). Reserved spec points: `RTO`, `RTLO`, `RTLC`, `RTLM` +Reserved for `RealtimeObject` feature specification, see [objects-features](../objects-features). Reserved spec points: `RTO`, `RTLO`, `RTLC`, `RTLM`, `RTPO`, `RTINS` ### RealtimeAnnotations {#realtime-annotations} diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 92bb8daef..ba4bed90d 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -19,7 +19,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO23a)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - `(RTO23b)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - `(RTO23c)` If the [RTO17](#RTO17) sync state is not `SYNCED`, waits for the sync state to transition to `SYNCED` - - `(RTO23d)` Returns the object with id `root` from the internal `ObjectsPool` as a `LiveMap` + - `(RTO23d)` Returns a `PathObject` ([RTPO1](#RTPO1)) wrapping the `LiveMap` with id `root` from the internal `ObjectsPool`. The `PathObject` is created with an empty path, rooted at the `root` `LiveMap` - `(RTO11)` `RealtimeObject#createMap` function: - `(RTO11a)` Expects the following arguments: - `(RTO11a1)` `entries` `Dict` (optional) - the initial entries for the new `LiveMap` object @@ -722,13 +722,170 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM22b2)` For each key that exists in the non-tombstoned entries of `newData` but does not exist in the non-tombstoned entries of `previousData`, add the key to `LiveMapUpdate.update` with the value `updated` - `(RTLM22b3)` For each key that exists in the non-tombstoned entries of both `previousData` and `newData`, perform a deep comparison of the `data` attributes from `previousData` and `newData`. If the data values differ, add the key to `LiveMapUpdate.update` with the value `updated` +### PathObject + +A `PathObject` is a lazy, path-based reference into the LiveObjects tree. It stores a path (as an ordered list of string segments) from the root `LiveMap` and resolves it at the time each method is called. This means a `PathObject` survives object replacements: if the object at a given path changes (e.g. via a `MAP_SET` operation), the same `PathObject` will resolve to the new object on subsequent calls. + +A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which returns a `PathObject` rooted at the channel's root `LiveMap` with an empty path. Further `PathObjects` are obtained by navigating with `PathObject#get` or `PathObject#at`. + +- `(RTPO1)` The `PathObject` class provides a path-based view over the LiveObjects tree + - `(RTPO1a)` A specific SDK implementation may choose to expose a subset of the methods available on the `PathObject` class based on the expected type at the path. For example, when the user provides a type structure as a generic type parameter to `RealtimeObject#get`, the SDK may use type-specific class names (e.g. `LiveMapPathObject`, `LiveCounterPathObject`, `PrimitivePathObject`) that only expose the methods applicable to that type. The specification describes the general `PathObject` class with the full set of methods +- `(RTPO2)` `PathObject` has the following internal properties: + - `(RTPO2a)` `path` - an ordered list of string segments representing the path from the root `LiveMap` to this position in the tree + - `(RTPO2b)` `root` - a reference to the root `LiveMap` instance from the internal `ObjectsPool` +- `(RTPO3)` Internal path resolution procedure - resolves the stored `path` against the LiveObjects tree: + - `(RTPO3a)` Starting from `root`, walk through the path segments in order. For each segment: + - `(RTPO3a1)` The current object must be a `LiveMap`. If it is not, the resolution has failed + - `(RTPO3a2)` Look up the segment as a key in the current `LiveMap` using `LiveMap#get` ([RTLM5](#RTLM5)). If the result is undefined/null, the resolution has failed + - `(RTPO3a3)` The result becomes the current object for the next segment + - `(RTPO3b)` If the path is empty, the result is the `root` `LiveMap` itself + - `(RTPO3c)` On resolution failure: + - `(RTPO3c1)` For read operations (`value`, `instance`, `entries`, `keys`, `values`, `size`, `compact`, `compactJson`), return undefined/null. The client library may log a debug or trace message + - `(RTPO3c2)` For write operations (`set`, `remove`, `increment`, `decrement`), the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92005, indicating that the path could not be resolved +- `(RTPO4)` `PathObject#path` function: + - `(RTPO4a)` Returns a dot-delimited string representation of the stored path segments + - `(RTPO4b)` Any dot characters (`.`) occurring within individual path segments must be escaped with a backslash (`\`) in the returned string. For example, a path with segments `["a", "b.c", "d"]` is represented as `a.b\.c.d` + - `(RTPO4c)` An empty path (root `PathObject`) returns an empty string +- `(RTPO5)` `PathObject#get` function: + - `(RTPO5a)` Expects the following arguments: + - `(RTPO5a1)` `key` `String` - the key to navigate to + - `(RTPO5b)` If `key` is not of type `String`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that the key must be a `String` + - `(RTPO5c)` Returns a new `PathObject` with the same `root` and with `key` appended to the current `path` segments + - `(RTPO5d)` This is purely navigational and does not resolve the path or access any `LiveObject` data +- `(RTPO6)` `PathObject#at` function: + - `(RTPO6a)` Expects the following arguments: + - `(RTPO6a1)` `path` `String` - a dot-delimited path string + - `(RTPO6b)` Parses the dot-delimited `path` string into individual segments, respecting backslash-escaped dots (a `\.` sequence is treated as a literal dot within a segment, not a separator) + - `(RTPO6c)` Returns a new `PathObject` with the same `root` and with the parsed segments appended to the current `path` segments + - `(RTPO6d)` This is a convenience for chaining multiple `PathObject#get` calls. For example, `pathObject.at("a.b.c")` is equivalent to `pathObject.get("a").get("b").get("c")` +- `(RTPO7)` `PathObject#value` function: + - `(RTPO7a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO7b)` If the resolved value is a `LiveCounter`, returns its current numeric value (equivalent to `LiveCounter#value`, see [RTLC5](#RTLC5)) + - `(RTPO7c)` If the resolved value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns the value directly + - `(RTPO7d)` If the resolved value is a `LiveMap`, returns undefined/null + - `(RTPO7e)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) +- `(RTPO8)` `PathObject#instance` function: + - `(RTPO8a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO8b)` If the resolved value is a `LiveObject` (i.e. a `LiveMap` or `LiveCounter`), returns a new `Instance` ([RTINS1](#RTINS1)) wrapping that `LiveObject` + - `(RTPO8c)` If the resolved value is a primitive, returns undefined/null + - `(RTPO8d)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) +- `(RTPO9)` `PathObject#entries` function: + - `(RTPO9a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO9b)` If the resolved value is a `LiveMap`, returns an iterator yielding `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` + - `(RTPO9c)` Only non-tombstoned entries are included, following the same rules as `LiveMap#entries` ([RTLM11](#RTLM11)) + - `(RTPO9d)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty iterator +- `(RTPO10)` `PathObject#keys` function: + - `(RTPO10a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that it yields only the keys +- `(RTPO11)` `PathObject#values` function: + - `(RTPO11a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that it yields only the `PathObject` values +- `(RTPO12)` `PathObject#size` function: + - `(RTPO12a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO12b)` If the resolved value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10)) + - `(RTPO12c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns undefined/null +- `(RTPO13)` `PathObject#compact` function: + - `(RTPO13a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO13b)` If the resolved value is a `LiveMap`, returns a recursively compacted representation as a plain key-value object: + - `(RTPO13b1)` Each entry in the `LiveMap` is included in the result. Tombstoned entries are excluded + - `(RTPO13b2)` Nested `LiveMap` values are recursively compacted into nested plain key-value objects + - `(RTPO13b3)` Nested `LiveCounter` values are resolved to their numeric value + - `(RTPO13b4)` Primitive values (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`) are included as-is + - `(RTPO13b5)` Cyclic references (a `LiveMap` that has already been visited during this compaction) are represented by reusing the same in-memory object reference to the already-compacted result for that `LiveMap` + - `(RTPO13c)` If the resolved value is a `LiveCounter`, returns its current numeric value (equivalent to `PathObject#value`) + - `(RTPO13d)` If the resolved value is a primitive, returns the value directly (equivalent to `PathObject#value`) + - `(RTPO13e)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) +- `(RTPO14)` `PathObject#compactJson` function: + - `(RTPO14a)` Behaves identically to `PathObject#compact` ([RTPO13](#RTPO13)) except for the following differences, which ensure the result is JSON-serializable: + - `(RTPO14a1)` `Binary` values are encoded as base64 strings instead of being included as-is + - `(RTPO14a2)` Cyclic references are represented as an object with a single `objectId` property containing the Object ID of the referenced `LiveMap`, instead of reusing the in-memory object reference +- `(RTPO15)` `PathObject#set` function: + - `(RTPO15a)` Expects the following arguments: + - `(RTPO15a1)` `key` `String` - the key to set the value for + - `(RTPO15a2)` `value` - the value to assign to the key. Accepted types are the same as for `LiveMap#set` ([RTLM20](#RTLM20)) + - `(RTPO15b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) + - `(RTPO15c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#set` ([RTLM20](#RTLM20)) with the provided `key` and `value` + - `(RTPO15d)` If the resolved value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that the operation is not supported for the resolved object type +- `(RTPO16)` `PathObject#remove` function: + - `(RTPO16a)` Expects the following arguments: + - `(RTPO16a1)` `key` `String` - the key to remove the value for + - `(RTPO16b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) + - `(RTPO16c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#remove` ([RTLM21](#RTLM21)) with the provided `key` + - `(RTPO16d)` If the resolved value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 +- `(RTPO17)` `PathObject#increment` function: + - `(RTPO17a)` Expects the following arguments: + - `(RTPO17a1)` `amount` `Number` (optional) - the amount by which to increment the counter value. Defaults to 1 + - `(RTPO17b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) + - `(RTPO17c)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#increment` ([RTLC12](#RTLC12)) with the provided `amount` + - `(RTPO17d)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 +- `(RTPO18)` `PathObject#decrement` function: + - `(RTPO18a)` Expects the following arguments: + - `(RTPO18a1)` `amount` `Number` (optional) - the amount by which to decrement the counter value. Defaults to 1 + - `(RTPO18b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) + - `(RTPO18c)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#decrement` ([RTLC13](#RTLC13)) with the provided `amount` + - `(RTPO18d)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + +### Instance + +An `Instance` holds a direct reference to a specific resolved `LiveObject` or primitive value. Unlike `PathObject` which is path-addressed and re-resolves on each call, `Instance` is identity-addressed: it follows the specific object it was created with, regardless of where that object sits in the tree. + +- `(RTINS1)` The `Instance` class provides a direct-reference view of a `LiveObject` or primitive value + - `(RTINS1a)` A specific SDK implementation may choose to expose a subset of the methods available on the `Instance` class based on the known underlying type. For example, the SDK may use type-specific class names (e.g. `LiveMapInstance`, `LiveCounterInstance`, `PrimitiveInstance`) that only expose the methods applicable to the wrapped type. The specification describes the general `Instance` class with the full set of methods +- `(RTINS2)` `Instance` has the following internal properties: + - `(RTINS2a)` `value` - a reference to the wrapped `LiveObject` or primitive value +- `(RTINS3)` `Instance#id` property: + - `(RTINS3a)` If the wrapped value is a `LiveObject`, returns the `objectId` of that object + - `(RTINS3b)` If the wrapped value is a primitive, returns undefined/null +- `(RTINS4)` `Instance#value` function: + - `(RTINS4a)` If the wrapped value is a `LiveCounter`, returns its current numeric value (equivalent to `LiveCounter#value`, see [RTLC5](#RTLC5)) + - `(RTINS4b)` If the wrapped value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns the value directly + - `(RTINS4c)` If the wrapped value is a `LiveMap`, returns undefined/null +- `(RTINS5)` `Instance#get` function: + - `(RTINS5a)` Expects the following arguments: + - `(RTINS5a1)` `key` `String` - the key to look up + - `(RTINS5b)` If the wrapped value is a `LiveMap`, looks up the value at `key` using `LiveMap#get` ([RTLM5](#RTLM5)) and returns a new `Instance` wrapping the result. If the result is undefined/null, returns undefined/null + - `(RTINS5c)` If the wrapped value is not a `LiveMap`, returns undefined/null +- `(RTINS6)` `Instance#entries` function: + - `(RTINS6a)` If the wrapped value is a `LiveMap`, returns an iterator yielding `[key, Instance]` pairs, where each `Instance` wraps the corresponding entry value from `LiveMap#entries` ([RTLM11](#RTLM11)) + - `(RTINS6b)` If the wrapped value is not a `LiveMap`, returns an empty iterator +- `(RTINS7)` `Instance#keys` function: + - `(RTINS7a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that it yields only the keys +- `(RTINS8)` `Instance#values` function: + - `(RTINS8a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that it yields only the `Instance` values +- `(RTINS9)` `Instance#size` function: + - `(RTINS9a)` If the wrapped value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10)) + - `(RTINS9b)` If the wrapped value is not a `LiveMap`, returns undefined/null +- `(RTINS10)` `Instance#compact` function: + - `(RTINS10a)` Behaves identically to `PathObject#compact` ([RTPO13](#RTPO13)), but operates on the wrapped value directly instead of resolving a path +- `(RTINS11)` `Instance#compactJson` function: + - `(RTINS11a)` Behaves identically to `PathObject#compactJson` ([RTPO14](#RTPO14)), but operates on the wrapped value directly instead of resolving a path +- `(RTINS12)` `Instance#set` function: + - `(RTINS12a)` Expects the following arguments: + - `(RTINS12a1)` `key` `String` - the key to set the value for + - `(RTINS12a2)` `value` - the value to assign to the key. Accepted types are the same as for `LiveMap#set` ([RTLM20](#RTLM20)) + - `(RTINS12b)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#set` ([RTLM20](#RTLM20)) with the provided `key` and `value` + - `(RTINS12c)` If the wrapped value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 +- `(RTINS13)` `Instance#remove` function: + - `(RTINS13a)` Expects the following arguments: + - `(RTINS13a1)` `key` `String` - the key to remove the value for + - `(RTINS13b)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#remove` ([RTLM21](#RTLM21)) with the provided `key` + - `(RTINS13c)` If the wrapped value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 +- `(RTINS14)` `Instance#increment` function: + - `(RTINS14a)` Expects the following arguments: + - `(RTINS14a1)` `amount` `Number` (optional) - the amount by which to increment the counter value. Defaults to 1 + - `(RTINS14b)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#increment` ([RTLC12](#RTLC12)) with the provided `amount` + - `(RTINS14c)` If the wrapped value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 +- `(RTINS15)` `Instance#decrement` function: + - `(RTINS15a)` Expects the following arguments: + - `(RTINS15a1)` `amount` `Number` (optional) - the amount by which to decrement the counter value. Defaults to 1 + - `(RTINS15b)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#decrement` ([RTLC13](#RTLC13)) with the provided `amount` + - `(RTINS15c)` If the wrapped value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + ## Interface Definition {#idl} Describes types for RealtimeObject.\ Types and their properties/methods are public and exposed to users by default. An `internal` label may be used to indicate that a type or its property/method must not be exposed to users and is intended for internal SDK use only. class RealtimeObject: // RTO* - get() => io LiveMap // RTO23 + get() => io PathObject // RTO23 createMap(Dict entries?) => io LiveMap // RTO11 createCounter(Number count?) => io LiveCounter // RTO12 on(ObjectsEvent event, (() ->) callback) -> StatusSubscription // RTO18 @@ -790,3 +947,35 @@ Types and their properties/methods are public and exposed to users by default. A interface LiveMapUpdate extends LiveObjectUpdate: // RTLM18, RTLM18a update: Dict // RTLM18b + + class PathObject: // RTPO* + path() -> String // RTPO4 + get(String key) -> PathObject // RTPO5 + at(String path) -> PathObject // RTPO6 + value() -> (Boolean | Binary | Number | String | JsonArray | JsonObject)? // RTPO7 + instance() -> Instance? // RTPO8 + entries() -> Iterator<[String, PathObject]> // RTPO9 + keys() -> Iterator // RTPO10 + values() -> Iterator // RTPO11 + size() -> Number? // RTPO12 + compact() -> Object? // RTPO13 + compactJson() -> Object? // RTPO14 + set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap) value) => io // RTPO15 + remove(String key) => io // RTPO16 + increment(Number amount?) => io // RTPO17 + decrement(Number amount?) => io // RTPO18 + + class Instance: // RTINS* + id: String? // RTINS3 + value() -> (Boolean | Binary | Number | String | JsonArray | JsonObject)? // RTINS4 + get(String key) -> Instance? // RTINS5 + entries() -> Iterator<[String, Instance]> // RTINS6 + keys() -> Iterator // RTINS7 + values() -> Iterator // RTINS8 + size() -> Number? // RTINS9 + compact() -> Object? // RTINS10 + compactJson() -> Object? // RTINS11 + set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap) value) => io // RTINS12 + remove(String key) => io // RTINS13 + increment(Number amount?) => io // RTINS14 + decrement(Number amount?) => io // RTINS15 From b52e37ff76af64870b7212038cdc2f93a9ed326a Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 24 Feb 2026 12:12:31 +0000 Subject: [PATCH 02/43] LiveMap and LiveCounter creation via value types --- specifications/features.md | 6 +- specifications/objects-features.md | 313 ++++++++++++++++++----------- 2 files changed, 194 insertions(+), 125 deletions(-) diff --git a/specifications/features.md b/specifications/features.md index 32f5a0ab7..113232331 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -939,7 +939,7 @@ The threading and/or asynchronous model for each realtime library will vary by l ### RealtimeObject {#realtime-objects} -Reserved for `RealtimeObject` feature specification, see [objects-features](../objects-features). Reserved spec points: `RTO`, `RTLO`, `RTLC`, `RTLM`, `RTPO`, `RTINS` +Reserved for `RealtimeObject` feature specification, see [objects-features](../objects-features). Reserved spec points: `RTO`, `RTLO`, `RTLC`, `RTLM`, `RTPO`, `RTINS`, `RTLCV`, `RTLMV` ### RealtimeAnnotations {#realtime-annotations} @@ -1331,13 +1331,13 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(OOP4g)` The size is the sum of the sizes of the map create, `mapSet`, `mapRemove`, counter create, and `counterInc` components - `(OOP4h)` The size of the map create component is: - `(OOP4h1)` If `mapCreate` is present, it is equal to the size of `mapCreate` calculated per [MCR3](#MCR3) - - `(OOP4h2)` Else if `mapCreateWithObjectId` is present, it is equal to the size of the `MapCreate` retained in [RTO11f18](objects-features#RTO11f18), calculated per [MCR3](#MCR3) + - `(OOP4h2)` Else if `mapCreateWithObjectId` is present, it is equal to the size of the `MapCreate` retained in [RTLMV4j5](objects-features#RTLMV4j5), calculated per [MCR3](#MCR3) - `(OOP4h3)` Otherwise it is zero - `(OOP4i)` The size of the `mapSet` property is calculated per [MST3](#MST3) - `(OOP4j)` The size of the `mapRemove` property is calculated per [MRM3](#MRM3) - `(OOP4k)` The size of the counter create component is: - `(OOP4k1)` If `counterCreate` is present, it is equal to the size of `counterCreate` calculated per [CCR3](#CCR3) - - `(OOP4k2)` Else if `counterCreateWithObjectId` is present, it is equal to the size of the `CounterCreate` retained in [RTO12f16](objects-features#RTO12f16), calculated per [CCR3](#CCR3) + - `(OOP4k2)` Else if `counterCreateWithObjectId` is present, it is equal to the size of the `CounterCreate` retained in [RTLCV4g5](objects-features#RTLCV4g5), calculated per [CCR3](#CCR3) - `(OOP4k3)` Otherwise it is zero - `(OOP4l)` The size of the `counterInc` property is calculated per [CIN3](#CIN3) - `(OOP4f)` The size of a `null` or omitted property is zero diff --git a/specifications/objects-features.md b/specifications/objects-features.md index ba4bed90d..a66dddfc5 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -20,17 +20,17 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO23b)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - `(RTO23c)` If the [RTO17](#RTO17) sync state is not `SYNCED`, waits for the sync state to transition to `SYNCED` - `(RTO23d)` Returns a `PathObject` ([RTPO1](#RTPO1)) wrapping the `LiveMap` with id `root` from the internal `ObjectsPool`. The `PathObject` is created with an empty path, rooted at the `root` `LiveMap` -- `(RTO11)` `RealtimeObject#createMap` function: - - `(RTO11a)` Expects the following arguments: - - `(RTO11a1)` `entries` `Dict` (optional) - the initial entries for the new `LiveMap` object - - `(RTO11b)` The return type is a `LiveMap`, which is returned once the required I/O has successfully completed - - `(RTO11c)` Requires the `OBJECT_PUBLISH` channel mode to be granted per [RTO2](#RTO2) - - `(RTO11d)` If the channel is in the `DETACHED`, `FAILED` or `SUSPENDED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - - `(RTO11e)` If [`echoMessages`](../features#TO3h) client option is `false`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40000, indicating that `echoMessages` must be enabled for this operation - - `(RTO11f)` Creates an `ObjectMessage` for a `MAP_CREATE` action in the following way: - - `(RTO11f1)` If `entries` is null or not of type `Dict`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that `entries` must be a `Dict`. Note that `entries` is an optional argument, and if omitted, this error must not be thrown - - `(RTO11f2)` If any of the keys provided in `entries` are not of type `String`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that keys must be `String` - - `(RTO11f3)` If any of the values provided in `entries` are not of an expected type, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40013, indicating that such data type is unsupported +- `(RTO11)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11a)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11a1)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11b)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11c)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11d)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11e)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f1)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f2)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f3)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11f4)` This clause has been replaced by [RTO11f14](#RTO11f14) as of specification version 6.0.0. - `(RTO11f4a)` This clause has been replaced by [RTO11f14a](#RTO11f14a) as of specification version 6.0.0. - `(RTO11f4b)` This clause has been replaced by [RTO11f14b](#RTO11f14b) as of specification version 6.0.0. @@ -43,98 +43,83 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO11f4c1e)` This clause has been replaced by [RTO11f14c1e](#RTO11f14c1e) as of specification version 6.0.0. - `(RTO11f4c1f)` This clause has been replaced by [RTO11f14c1f](#RTO11f14c1f) as of specification version 6.0.0. - `(RTO11f4c2)` This clause has been replaced by [RTO11f14c2](#RTO11f14c2) as of specification version 6.0.0. - - `(RTO11f14)` Create a `MapCreate` object with the initial value for the new `LiveMap`: - - `(RTO11f14a)` Set `MapCreate.semantics` to `ObjectsMapSemantics.LWW` - - `(RTO11f14b)` Set `MapCreate.entries` to an empty map if `entries` is omitted - - `(RTO11f14c)` Otherwise, set `MapCreate.entries` based on the provided `entries`. For each key-value pair in `entries`: - - `(RTO11f14c1)` Create an `ObjectsMapEntry` for the current value: - - `(RTO11f14c1a)` If the value is of type `LiveCounter` or `LiveMap`, set `ObjectsMapEntry.data.objectId` to the `objectId` of that object - - `(RTO11f14c1b)` If the value is of type `JsonArray` or `JsonObject`, set `ObjectsMapEntry.data.json` to that value - - `(RTO11f14c1c)` If the value is of type `String`, set `ObjectsMapEntry.data.string` to that value - - `(RTO11f14c1d)` If the value is of type `Number`, set `ObjectsMapEntry.data.number` to that value - - `(RTO11f14c1e)` If the value is of type `Boolean`, set `ObjectsMapEntry.data.boolean` to that value - - `(RTO11f14c1f)` If the value is of type `Binary`, set `ObjectsMapEntry.data.bytes` to that value - - `(RTO11f14c2)` Add a new entry to `MapCreate.entries` with the current key and the created `ObjectsMapEntry` as the value + - `(RTO11f14)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14a)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14b)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c1)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c1a)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c1b)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c1c)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c1d)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c1e)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c1f)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f14c2)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11f5)` This clause has been replaced by [RTO11f15](#RTO11f15) as of specification version 6.0.0. - - `(RTO11f15)` Create an initial value JSON string based on `MapCreate` object from [RTO11f14](#RTO11f14) as follows: - - `(RTO11f15a)` The `MapCreate` object may contain user-provided `ObjectData` that requires encoding. Encode the `ObjectData` values using the procedure described in [OD4](../features#OD4) - - `(RTO11f15b)` Return a JSON string representation of the encoded `MapCreate` object - - `(RTO11f6)` Create a unique string nonce with 16+ characters; the nonce is used to ensure object ID uniqueness across clients - - `(RTO11f7)` Get the current server time as described in [RTO16](#RTO16) - - `(RTO11f8)` Create an `objectId` for the new `LiveMap` object as described in [RTO14](#RTO14), passing in `map` string as the `type`, the initial value JSON string from [RTO11f15](#RTO11f15), the nonce from [RTO11f6](#RTO11f6), and the server time from [RTO11f7](#RTO11f7) - - `(RTO11f9)` Set `ObjectMessage.operation.action` to `ObjectOperationAction.MAP_CREATE` - - `(RTO11f10)` Set `ObjectMessage.operation.objectId` to the `objectId` created in [RTO11f8](#RTO11f8) + - `(RTO11f15)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f15a)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f15b)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f6)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f7)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f8)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f9)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f10)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11f11)` This clause has been replaced by [RTO11f16](#RTO11f16) as of specification version 6.0.0. - `(RTO11f12)` This clause has been replaced by [RTO11f17](#RTO11f17) as of specification version 6.0.0. - `(RTO11f13)` This clause has been deleted as of specification version 6.0.0. - - `(RTO11f16)` Set `ObjectMessage.operation.mapCreateWithObjectId.nonce` to the nonce value created in [RTO11f6](#RTO11f6) - - `(RTO11f17)` Set `ObjectMessage.operation.mapCreateWithObjectId.initialValue` to the JSON string created in [RTO11f15](#RTO11f15) - - `(RTO11f18)` The client library must retain the `MapCreate` object from [RTO11f14](#RTO11f14) alongside the `MapCreateWithObjectId`. It is the operation from which the `MapCreateWithObjectId` was derived, and is needed for message size calculation ([OOP4h2](../features#OOP4h2)) and local application of the operation ([RTLM23](#RTLM23)). This `MapCreate` is for local use only and must not be sent over the wire. + - `(RTO11f16)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f17)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11f18)` This clause has been replaced by [RTLMV4j5](#RTLMV4j5). - `(RTO11g)` This clause has been replaced by [RTO11i](#RTO11i) - - `(RTO11i)` Publishes the `ObjectMessage` from [RTO11f](#RTO11f) using `RealtimeObject#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array - - `(RTO11i1)` The client library waits for the publish operation I/O to complete. On failure, an error is returned to the caller; on success, the `createMap` operation continues - - `(RTO11h)` Returns a `LiveMap` instance: + - `(RTO11i)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11i1)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11h)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11h1)` This clause has been deleted. - - `(RTO11h2)` If an object with the `ObjectMessage.operation.objectId` exists in the internal `ObjectsPool`, return it - - `(RTO11h3)` Otherwise, if the object does not exist in the internal `ObjectsPool`: + - `(RTO11h2)` This clause has been replaced by [RTLMV3](#RTLMV3). + - `(RTO11h3)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11h3a)` This clause has been deleted. - `(RTO11h3b)` This clause has been deleted. - `(RTO11h3c)` This clause has been deleted. - - `(RTO11h3d)` The library should throw an `ErrorInfo` error with `statusCode` 500 and `code` 50000 (Note: this is not expected to happen since the object should have been created as part of applying the `MAP_CREATE` operation via `publishAndApply` in [RTO11i](#RTO11i)) -- `(RTO12)` `RealtimeObject#createCounter` function: - - `(RTO12a)` Expects the following arguments: - - `(RTO12a1)` `count` `Number` (optional) - the initial count for the new `LiveCounter` object - - `(RTO12b)` The return type is a `LiveCounter`, which is returned once the required I/O has successfully completed - - `(RTO12c)` Requires the `OBJECT_PUBLISH` channel mode to be granted per [RTO2](#RTO2) - - `(RTO12d)` If the channel is in the `DETACHED`, `FAILED` or `SUSPENDED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - - `(RTO12e)` If [`echoMessages`](../features#TO3h) client option is `false`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40000, indicating that `echoMessages` must be enabled for this operation - - `(RTO12f)` Creates an `ObjectMessage` for a `COUNTER_CREATE` action in the following way: - - `(RTO12f1)` If `count` is null, not of type `Number`, or not a finite number, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that `count` must be a valid number. Note that `count` is an optional argument, and if omitted, this error must not be thrown + - `(RTO11h3d)` This clause has been replaced by [RTLMV3](#RTLMV3). +- `(RTO12)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12a)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12a1)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12b)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12c)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12d)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12e)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f1)` This clause has been replaced by [RTLCV3](#RTLCV3). - `(RTO12f2)` This clause has been replaced by [RTO12f12](#RTO12f12) as of specification version 6.0.0. - `(RTO12f2a)` This clause has been replaced by [RTO12f12a](#RTO12f12a) as of specification version 6.0.0. - `(RTO12f2b)` This clause has been replaced by [RTO12f12b](#RTO12f12b) as of specification version 6.0.0. - - `(RTO12f12)` Create a `CounterCreate` object with the initial value for the new `LiveCounter`: - - `(RTO12f12a)` Set `CounterCreate.count` to 0 if `count` is omitted - - `(RTO12f12b)` Otherwise, set `CounterCreate.count` to the provided `count` value + - `(RTO12f12)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f12a)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f12b)` This clause has been replaced by [RTLCV3](#RTLCV3). - `(RTO12f3)` This clause has been replaced by [RTO12f13](#RTO12f13) as of specification version 6.0.0. - - `(RTO12f13)` Create an initial value JSON string by generating a JSON string representation of the `CounterCreate` object from [RTO12f12](#RTO12f12) - - `(RTO12f4)` Create a unique string nonce with 16+ characters; the nonce is used to ensure object ID uniqueness across clients - - `(RTO12f5)` Get the current server time as described in [RTO16](#RTO16) - - `(RTO12f6)` Create an `objectId` for the new `LiveCounter` object as described in [RTO14](#RTO14), passing in `counter` string as the `type`, the initial value JSON string from [RTO12f13](#RTO12f13), the nonce from [RTO12f4](#RTO12f4), and the server time from [RTO12f5](#RTO12f5) - - `(RTO12f7)` Set `ObjectMessage.operation.action` to `ObjectOperationAction.COUNTER_CREATE` - - `(RTO12f8)` Set `ObjectMessage.operation.objectId` to the `objectId` created in [RTO12f6](#RTO12f6) + - `(RTO12f13)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f4)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f5)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f6)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f7)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f8)` This clause has been replaced by [RTLCV3](#RTLCV3). - `(RTO12f9)` This clause has been replaced by [RTO12f14](#RTO12f14) as of specification version 6.0.0. - `(RTO12f10)` This clause has been replaced by [RTO12f15](#RTO12f15) as of specification version 6.0.0. - `(RTO12f11)` This clause has been deleted as of specification version 6.0.0. - -\* `(RTO12f14)` Set `ObjectMessage.operation.counterCreateWithObjectId.nonce` to the nonce value created in [RTO12f4](#RTO12f4) - -\* `(RTO12f15)` Set `ObjectMessage.operation.counterCreateWithObjectId.initialValue` to the JSON string created in [RTO12f13](#RTO12f13) - -\* `(RTO12f16)` The client library must retain the `CounterCreate` object from [RTO12f12](#RTO12f12) alongside the `CounterCreateWithObjectId`. It is the operation from which the `CounterCreateWithObjectId` was derived, and is needed for message size calculation ([OOP4k2](../features#OOP4k2)) and local application of the operation ([RTLC16](#RTLC16)). This `CounterCreate` is for local use only and must not be sent over the wire. - -`(RTO12g)` This clause has been replaced by [RTO12i](#RTO12i) - -`(RTO12i)` Publishes the `ObjectMessage` from [RTO12f](#RTO12f) using `RealtimeObject#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array - -\* `(RTO12i1)` The client library waits for the publish operation I/O to complete. On failure, an error is returned to the caller; on success, the `createCounter` operation continues - -`(RTO12h)` Returns a `LiveCounter` instance: - -\* `(RTO12h1)` This clause has been deleted. - -\* `(RTO12h2)` If an object with the `ObjectMessage.operation.objectId` exists in the internal `ObjectsPool`, return it - -\* `(RTO12h3)` Otherwise, if the object does not exist in the internal `ObjectsPool`: - -`(RTO12h3a)` This clause has been deleted. - -`(RTO12h3b)` This clause has been deleted. - -`(RTO12h3c)` This clause has been deleted. - -`(RTO12h3d)` The library should throw an `ErrorInfo` error with `statusCode` 500 and `code` 50000 (Note: this is not expected to happen since the object should have been created as part of applying the `COUNTER_CREATE` operation via `publishAndApply` in [RTO12i](#RTO12i)) - + - `(RTO12f14)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f15)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12f16)` This clause has been replaced by [RTLCV4g5](#RTLCV4g5). + - `(RTO12g)` This clause has been replaced by [RTO12i](#RTO12i) + - `(RTO12i)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12i1)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12h)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12h1)` This clause has been deleted. + - `(RTO12h2)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12h3)` This clause has been replaced by [RTLCV3](#RTLCV3). + - `(RTO12h3a)` This clause has been deleted. + - `(RTO12h3b)` This clause has been deleted. + - `(RTO12h3c)` This clause has been deleted. + - `(RTO12h3d)` This clause has been replaced by [RTLCV3](#RTLCV3). - `(RTO2)` Certain object operations may require a specific channel mode to be set on a channel in order to be performed. If a specific channel mode is required by an operation, then: - `(RTO2a)` If the channel is in the `ATTACHED` state, the presence of the required channel mode is checked against the set of channel modes granted by the server per [RTL4m](../features#RTL4m) : - `(RTO2a1)` If the channel mode is in the set, the operation is allowed @@ -459,7 +444,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLC10b)` This clause has been replaced by [RTLC16b](#RTLC16b) as of specification version 6.0.0. - `(RTLC10c)` This clause has been replaced by [RTLC16c](#RTLC16c) as of specification version 6.0.0. - `(RTLC10d)` This clause has been replaced by [RTLC16d](#RTLC16d) as of specification version 6.0.0. -- `(RTLC16)` The initial value from an `ObjectOperation` can be merged into this `LiveCounter` in the following way. Let `counterCreate` be `ObjectOperation.counterCreate` if present, else the `CounterCreate` from which `ObjectOperation.counterCreateWithObjectId` was derived (see [RTO12f16](#RTO12f16)): +- `(RTLC16)` The initial value from an `ObjectOperation` can be merged into this `LiveCounter` in the following way. Let `counterCreate` be `ObjectOperation.counterCreate` if present, else the `CounterCreate` from which `ObjectOperation.counterCreateWithObjectId` was derived (see [RTLCV4g5](#RTLCV4g5)): - `(RTLC16a)` Add `counterCreate.count` to `data`, if it exists - `(RTLC16b)` Set the private flag `createOperationIsMerged` to `true` - `(RTLC16c)` If `counterCreate.count` exists, return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to `counterCreate.count` @@ -528,12 +513,13 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM20)` `LiveMap#set` function: - `(RTLM20a)` Expects the following arguments: - `(RTLM20a1)` `key` `String` - the key to set the value for - - `(RTLM20a2)` `value` `Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap` - the value to assign to the key + - `(RTLM20a2)` This clause has been replaced by [RTLM20a3](#RTLM20a3). + - `(RTLM20a3)` `value` `Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounterValueType | LiveMapValueType` - the value to assign to the key - `(RTLM20b)` Requires the `OBJECT_PUBLISH` channel mode to be granted per [RTO2](#RTO2) - `(RTLM20c)` If the channel is in the `DETACHED`, `FAILED` or `SUSPENDED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - `(RTLM20d)` If [`echoMessages`](../features#TO3h) client option is `false`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40000, indicating that `echoMessages` must be enabled for this operation - `(RTLM20e)` Creates an `ObjectMessage` for a `MAP_SET` action in the following way: - - `(RTLM20e1)` Validates the provided `key` and `value` in a similar way as described in [RTO11f2](#RTO11f2) and [RTO11f3](#RTO11f3) + - `(RTLM20e1)` Validates the provided `key` and `value` in a similar way as described in [RTLMV4b](#RTLMV4b) and [RTLMV4c](#RTLMV4c) - `(RTLM20e2)` Set `ObjectMessage.operation.action` to `ObjectOperationAction.MAP_SET` - `(RTLM20e3)` Set `ObjectMessage.operation.objectId` to the Object ID of this `LiveMap` - `(RTLM20e4)` This clause has been replaced by [RTLM20e6](#RTLM20e6) as of specification version 6.0.0. @@ -546,14 +532,20 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM20e5f)` This clause has been replaced by [RTLM20e7f](#RTLM20e7f) as of specification version 6.0.0. - `(RTLM20e6)` Set `ObjectMessage.operation.mapSet.key` to the provided `key` value - `(RTLM20e7)` Set `ObjectMessage.operation.mapSet.value` depending on the type of the provided `value`: - - `(RTLM20e7a)` If the `value` is of type `LiveCounter` or `LiveMap`, set `ObjectMessage.operation.mapSet.value.objectId` to the `objectId` of that object + - `(RTLM20e7a)` This clause has been replaced by [RTLM20e7g](#RTLM20e7g). + - `(RTLM20e7g)` If the `value` is of type `LiveCounterValueType` or `LiveMapValueType`: + - `(RTLM20e7g1)` Consume the value type per [RTLCV4](#RTLCV4) or [RTLMV4](#RTLMV4) respectively to generate `*_CREATE` `ObjectMessages`. Collect all generated `ObjectMessages` + - `(RTLM20e7g2)` Set `ObjectMessage.operation.mapSet.value.objectId` to the `objectId` from the outermost `*_CREATE` `ObjectMessage` - `(RTLM20e7b)` If the `value` is of type `JsonArray` or `JsonObject`, set `ObjectMessage.operation.mapSet.value.json` to that value - `(RTLM20e7c)` If the `value` is of type `String`, set `ObjectMessage.operation.mapSet.value.string` to that value - `(RTLM20e7d)` If the `value` is of type `Number`, set `ObjectMessage.operation.mapSet.value.number` to that value - `(RTLM20e7e)` If the `value` is of type `Boolean`, set `ObjectMessage.operation.mapSet.value.boolean` to that value - `(RTLM20e7f)` If the `value` is of type `Binary`, set `ObjectMessage.operation.mapSet.value.bytes` to that value - `(RTLM20f)` This clause has been replaced by [RTLM20g](#RTLM20g) - - `(RTLM20g)` Publishes the `ObjectMessage` from [RTLM20e](#RTLM20e) using `RealtimeObject#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array + - `(RTLM20g)` This clause has been replaced by [RTLM20h](#RTLM20h). + - `(RTLM20h)` Publishes all `ObjectMessages` using `RealtimeObject#publishAndApply` ([RTO20](#RTO20)): + - `(RTLM20h1)` If the `value` is of type `LiveCounterValueType` or `LiveMapValueType`, the array contains the `*_CREATE` `ObjectMessages` collected in [RTLM20e7g1](#RTLM20e7g1) followed by the `MAP_SET` `ObjectMessage` from [RTLM20e](#RTLM20e) + - `(RTLM20h2)` Otherwise, the `MAP_SET` `ObjectMessage` from [RTLM20e](#RTLM20e) is passed as a single element in the array - `(RTLM21)` `LiveMap#remove` function: - `(RTLM21a)` Expects the following arguments: - `(RTLM21a1)` `key` `String` - the key to remove the value for @@ -561,7 +553,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM21c)` If the channel is in the `DETACHED`, `FAILED` or `SUSPENDED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - `(RTLM21d)` If [`echoMessages`](../features#TO3h) client option is `false`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40000, indicating that `echoMessages` must be enabled for this operation - `(RTLM21e)` Creates an `ObjectMessage` for a `MAP_REMOVE` action in the following way: - - `(RTLM21e1)` Validates the provided `key` in a similar way as described in [RTO11f2](#RTO11f2) + - `(RTLM21e1)` Validates the provided `key` in a similar way as described in [RTLMV4b](#RTLMV4b) - `(RTLM21e2)` Set `ObjectMessage.operation.action` to `ObjectOperationAction.MAP_REMOVE` - `(RTLM21e3)` Set `ObjectMessage.operation.objectId` to the Object ID of this `LiveMap` - `(RTLM21e4)` This clause has been replaced by [RTLM21e5](#RTLM21e5) as of specification version 6.0.0. @@ -704,7 +696,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM17a2)` This clause has been replaced by [RTLM23a2](#RTLM23a2) as of specification version 6.0.0. - `(RTLM17b)` This clause has been replaced by [RTLM23b](#RTLM23b) as of specification version 6.0.0. - `(RTLM17c)` This clause has been replaced by [RTLM23c](#RTLM23c) as of specification version 6.0.0. -- `(RTLM23)` The initial value from an `ObjectOperation` can be merged into this `LiveMap` in the following way. Let `mapCreate` be `ObjectOperation.mapCreate` if present, else the `MapCreate` from which `ObjectOperation.mapCreateWithObjectId` was derived (see [RTO11f18](#RTO11f18)): +- `(RTLM23)` The initial value from an `ObjectOperation` can be merged into this `LiveMap` in the following way. Let `mapCreate` be `ObjectOperation.mapCreate` if present, else the `MapCreate` from which `ObjectOperation.mapCreateWithObjectId` was derived (see [RTLMV4j5](#RTLMV4j5)): - `(RTLM23a)` For each key-`ObjectsMapEntry` pair in `mapCreate.entries`: - `(RTLM23a1)` If `ObjectsMapEntry.tombstone` is `false` or omitted, apply the `MAP_SET` operation to the current key as described in [RTLM7](#RTLM7), passing in `ObjectsMapEntry.data` and the current key as `MapSet`, and `ObjectsMapEntry.timeserial` as `serial`. Store the returned `LiveMapUpdate` object for use in [RTLM23c](#RTLM23c) - `(RTLM23a2)` If `ObjectsMapEntry.tombstone` is `true`, apply the `MAP_REMOVE` operation to the current key as described in [RTLM8](#RTLM8), passing in the current key as `MapRemove`, `ObjectsMapEntry.timeserial` as `serial`, and `ObjectsMapEntry.serialTimestamp` as `serialTimestamp`. Store the returned `LiveMapUpdate` object for use in [RTLM23c](#RTLM23c) @@ -722,6 +714,77 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM22b2)` For each key that exists in the non-tombstoned entries of `newData` but does not exist in the non-tombstoned entries of `previousData`, add the key to `LiveMapUpdate.update` with the value `updated` - `(RTLM22b3)` For each key that exists in the non-tombstoned entries of both `previousData` and `newData`, perform a deep comparison of the `data` attributes from `previousData` and `newData`. If the data values differ, add the key to `LiveMapUpdate.update` with the value `updated` +### LiveCounterValueType + +A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCounter` object. It stores the desired initial count value and is consumed when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in `LiveMap.create` ([RTLMV3](#RTLMV3)). + +- `(RTLCV1)` `LiveCounterValueType` is an immutable value type representing the intent to create a new `LiveCounter` with a specific initial count +- `(RTLCV2)` `LiveCounterValueType` has the following internal properties: + - `(RTLCV2a)` `count` `Number` - the initial count value for the `LiveCounter` to be created +- `(RTLCV3)` `LiveCounter.create` static factory function: + - `(RTLCV3a)` Expects the following arguments: + - `(RTLCV3a1)` `initialCount` `Number` (optional) - the initial count for the new `LiveCounter` object. Defaults to 0 + - `(RTLCV3b)` Returns a new `LiveCounterValueType` instance with the internal `count` set to the provided `initialCount` (or 0 if omitted) + - `(RTLCV3c)` No input validation is performed at creation time. Validation is deferred to the consumption procedure ([RTLCV4](#RTLCV4)) + - `(RTLCV3d)` The returned `LiveCounterValueType` is immutable and must not be modified after creation +- `(RTLCV4)` Internal consumption procedure - when a `LiveCounterValueType` is consumed by a mutation method (e.g. `LiveMap#set` or as an entry value during `LiveMapValueType` consumption per [RTLMV4](#RTLMV4)), a `COUNTER_CREATE` `ObjectMessage` is generated as follows: + - `(RTLCV4a)` If the internal `count` is not undefined and (is not of type `Number` or is not a finite number), the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that the counter value must be a valid number + - `(RTLCV4b)` Create a `CounterCreate` object: + - `(RTLCV4b1)` Set `CounterCreate.count` to the internal `count` value, or to 0 if undefined + - `(RTLCV4c)` Create an initial value JSON string by generating a JSON string representation of the `CounterCreate` object + - `(RTLCV4d)` Create a unique string nonce with 16+ characters + - `(RTLCV4e)` Get the current server time as described in [RTO16](#RTO16) + - `(RTLCV4f)` Create an `objectId` for the new `LiveCounter` as described in [RTO14](#RTO14), passing in `counter` as the `type`, the initial value JSON string from [RTLCV4c](#RTLCV4c), the nonce from [RTLCV4d](#RTLCV4d), and the server time from [RTLCV4e](#RTLCV4e) + - `(RTLCV4g)` Create an `ObjectMessage` with: + - `(RTLCV4g1)` `ObjectMessage.operation.action` set to `ObjectOperationAction.COUNTER_CREATE` + - `(RTLCV4g2)` `ObjectMessage.operation.objectId` set to the `objectId` from [RTLCV4f](#RTLCV4f) + - `(RTLCV4g3)` `ObjectMessage.operation.counterCreateWithObjectId.nonce` set to the nonce from [RTLCV4d](#RTLCV4d) + - `(RTLCV4g4)` `ObjectMessage.operation.counterCreateWithObjectId.initialValue` set to the JSON string from [RTLCV4c](#RTLCV4c) + - `(RTLCV4g5)` The client library must retain the `CounterCreate` object from [RTLCV4b](#RTLCV4b) alongside the `CounterCreateWithObjectId`. It is the operation from which the `CounterCreateWithObjectId` was derived, and is needed for message size calculation ([OOP4k2](../features#OOP4k2)) and local application of the operation ([RTLC16](#RTLC16)). This `CounterCreate` is for local use only and must not be sent over the wire. + - `(RTLCV4h)` Return the `ObjectMessage` + +### LiveMapValueType + +A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` object. It stores the desired initial entries and is consumed when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in another `LiveMap.create` ([RTLMV3](#RTLMV3)) call. Supports arbitrarily deep nesting of `LiveMapValueType` and `LiveCounterValueType` values within entries. + +- `(RTLMV1)` `LiveMapValueType` is an immutable value type representing the intent to create a new `LiveMap` with specific initial entries +- `(RTLMV2)` `LiveMapValueType` has the following internal properties: + - `(RTLMV2a)` `entries` `Dict` (optional) - the initial entries for the `LiveMap` to be created +- `(RTLMV3)` `LiveMap.create` static factory function: + - `(RTLMV3a)` Expects the following arguments: + - `(RTLMV3a1)` `entries` `Dict` (optional) - the initial entries for the new `LiveMap` object + - `(RTLMV3b)` Returns a new `LiveMapValueType` instance with the internal `entries` set to the provided `entries` (or undefined if omitted) + - `(RTLMV3c)` No input validation is performed at creation time. Validation is deferred to the consumption procedure ([RTLMV4](#RTLMV4)) + - `(RTLMV3d)` The returned `LiveMapValueType` is immutable and must not be modified after creation +- `(RTLMV4)` Internal consumption procedure - when a `LiveMapValueType` is consumed by a mutation method (e.g. `LiveMap#set` or as an entry value during another `LiveMapValueType` consumption), `ObjectMessages` are generated as follows: + - `(RTLMV4a)` If the internal `entries` is not undefined and (is null or is not of type `Dict`), the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that entries must be a `Dict` + - `(RTLMV4b)` If any of the keys in the internal `entries` are not of type `String`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that keys must be `String` + - `(RTLMV4c)` If any of the values in the internal `entries` are not of an expected type, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40013, indicating that such data type is unsupported + - `(RTLMV4d)` Build entries for the `MapCreate` object. For each key-value pair in the internal `entries` (if present), create an `ObjectsMapEntry` for the value: + - `(RTLMV4d1)` If the value is of type `LiveCounterValueType`, consume it per [RTLCV4](#RTLCV4) to generate a `COUNTER_CREATE` `ObjectMessage`. Collect the generated `ObjectMessage` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the `ObjectMessage` + - `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively consume it per [RTLMV4](#RTLMV4) to generate `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the outermost `MAP_CREATE` `ObjectMessage` + - `(RTLMV4d3)` If the value is of type `JsonArray` or `JsonObject`, set `ObjectsMapEntry.data.json` to that value + - `(RTLMV4d4)` If the value is of type `String`, set `ObjectsMapEntry.data.string` to that value + - `(RTLMV4d5)` If the value is of type `Number`, set `ObjectsMapEntry.data.number` to that value + - `(RTLMV4d6)` If the value is of type `Boolean`, set `ObjectsMapEntry.data.boolean` to that value + - `(RTLMV4d7)` If the value is of type `Binary`, set `ObjectsMapEntry.data.bytes` to that value + - `(RTLMV4e)` Create a `MapCreate` object: + - `(RTLMV4e1)` Set `MapCreate.semantics` to `ObjectsMapSemantics.LWW` + - `(RTLMV4e2)` Set `MapCreate.entries` to an empty map if the internal `entries` is undefined, otherwise to the entries built in [RTLMV4d](#RTLMV4d) + - `(RTLMV4f)` Create an initial value JSON string based on the `MapCreate` object: + - `(RTLMV4f1)` The `MapCreate` object may contain user-provided `ObjectData` that requires encoding. Encode the `ObjectData` values using the procedure described in [OD4](../features#OD4) + - `(RTLMV4f2)` Return a JSON string representation of the encoded `MapCreate` object + - `(RTLMV4g)` Create a unique string nonce with 16+ characters + - `(RTLMV4h)` Get the current server time as described in [RTO16](#RTO16) + - `(RTLMV4i)` Create an `objectId` for the new `LiveMap` as described in [RTO14](#RTO14), passing in `map` as the `type`, the initial value JSON string from [RTLMV4f](#RTLMV4f), the nonce from [RTLMV4g](#RTLMV4g), and the server time from [RTLMV4h](#RTLMV4h) + - `(RTLMV4j)` Create an `ObjectMessage` with: + - `(RTLMV4j1)` `ObjectMessage.operation.action` set to `ObjectOperationAction.MAP_CREATE` + - `(RTLMV4j2)` `ObjectMessage.operation.objectId` set to the `objectId` from [RTLMV4i](#RTLMV4i) + - `(RTLMV4j3)` `ObjectMessage.operation.mapCreateWithObjectId.nonce` set to the nonce from [RTLMV4g](#RTLMV4g) + - `(RTLMV4j4)` `ObjectMessage.operation.mapCreateWithObjectId.initialValue` set to the JSON string from [RTLMV4f](#RTLMV4f) + - `(RTLMV4j5)` The client library must retain the `MapCreate` object from [RTLMV4e](#RTLMV4e) alongside the `MapCreateWithObjectId`. It is the operation from which the `MapCreateWithObjectId` was derived, and is needed for message size calculation ([OOP4h2](../features#OOP4h2)) and local application of the operation ([RTLM23](#RTLM23)). This `MapCreate` is for local use only and must not be sent over the wire. + - `(RTLMV4k)` Return an ordered array containing all `ObjectMessages` collected from nested value type consumptions in [RTLMV4d](#RTLMV4d) (in depth-first order), followed by the `MAP_CREATE` `ObjectMessage` from [RTLMV4j](#RTLMV4j) + ### PathObject A `PathObject` is a lazy, path-based reference into the LiveObjects tree. It stores a path (as an ordered list of string segments) from the root `LiveMap` and resolves it at the time each method is called. This means a `PathObject` survives object replacements: if the object at a given path changes (e.g. via a `MAP_SET` operation), the same `PathObject` will resolve to the new object on subsequent calls. @@ -886,8 +949,6 @@ Types and their properties/methods are public and exposed to users by default. A class RealtimeObject: // RTO* get() => io PathObject // RTO23 - createMap(Dict entries?) => io LiveMap // RTO11 - createCounter(Number count?) => io LiveCounter // RTO12 on(ObjectsEvent event, (() ->) callback) -> StatusSubscription // RTO18 off(() ->) // RTO19 publish(ObjectMessage[]) => io PublishResult // RTO15, internal @@ -909,45 +970,53 @@ Types and their properties/methods are public and exposed to users by default. A interface StatusSubscription: // RTO18f off() // RTO18f1 - class LiveObject: // RTLO* - objectId: String // RTLO3a, internal - siteTimeserials: Dict // RTLO3b, internal - createOperationIsMerged: Boolean // RTLO3c, internal - isTombstone: Boolean // RTLO3d, internal - tombstonedAt: Time? // RTLO3e, internal - canApplyOperation(ObjectMessage) -> Boolean // RTLO4a, internal - tombstone(ObjectMessage) // RTLO4e, internal + class LiveObject: // RTLO*, internal + objectId: String // RTLO3a + siteTimeserials: Dict // RTLO3b + createOperationIsMerged: Boolean // RTLO3c + isTombstone: Boolean // RTLO3d + tombstonedAt: Time? // RTLO3e + canApplyOperation(ObjectMessage) -> Boolean // RTLO4a + tombstone(ObjectMessage) // RTLO4e subscribe((LiveObjectUpdate) ->) -> LiveObjectSubscription // RTLO4b unsubscribe((LiveObjectUpdate) ->) // RTLO4c interface LiveObjectSubscription: // RTLO4b5 unsubscribe() // RTLO4b5a - interface LiveObjectUpdate: // RTLO4b4 + interface LiveObjectUpdate: // RTLO4b4, internal update: Object // RTLO4b4a - noop: Boolean // RTLO4b4b, internal + noop: Boolean // RTLO4b4b class LiveCounter extends LiveObject: // RTLC*, RTLC1 - value() -> Number // RTLC5 - increment(Number amount) => io // RTLC12 - decrement(Number amount) => io // RTLC13 + value() -> Number // RTLC5, internal + increment(Number amount) => io // RTLC12, internal + decrement(Number amount) => io // RTLC13, internal + static create(Number initialCount?) -> LiveCounterValueType // RTLCV3 - interface LiveCounterUpdate extends LiveObjectUpdate: // RTLC11, RTLC11a + interface LiveCounterUpdate extends LiveObjectUpdate: // RTLC11, RTLC11a, internal update: { amount: Number } // RTLC11b, RTLC11b1 - class LiveMap extends LiveObject: // RTLM*, RTLM1 + class LiveMap extends LiveObject: // RTLM*, RTLM1, internal clearTimeserial: String? // RTLM25, internal - get(key: String) -> (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)? // RTLM5 - size() -> Number // RTLM10 - entries() -> [String, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)?][] // RTLM11 - keys() -> String[] // RTLM12 - values() -> (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)?[] // RTLM13 - set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap) value) => io // RTLM20 - remove(String key) => io // RTLM21 - - interface LiveMapUpdate extends LiveObjectUpdate: // RTLM18, RTLM18a + get(key: String) -> (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)? // RTLM5, internal + size() -> Number // RTLM10, internal + entries() -> [String, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)?][] // RTLM11, internal + keys() -> String[] // RTLM12, internal + values() -> (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)?[] // RTLM13, internal + set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounterValueType | LiveMapValueType) value) => io // RTLM20, internal + remove(String key) => io // RTLM21, internal + static create(Dict entries?) -> LiveMapValueType // RTLMV3 + + interface LiveMapUpdate extends LiveObjectUpdate: // RTLM18, RTLM18a, internal update: Dict // RTLM18b + class LiveCounterValueType: // RTLCV* + // created via LiveCounter.create(), RTLCV3 + + class LiveMapValueType: // RTLMV* + // created via LiveMap.create(), RTLMV3 + class PathObject: // RTPO* path() -> String // RTPO4 get(String key) -> PathObject // RTPO5 @@ -960,7 +1029,7 @@ Types and their properties/methods are public and exposed to users by default. A size() -> Number? // RTPO12 compact() -> Object? // RTPO13 compactJson() -> Object? // RTPO14 - set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap) value) => io // RTPO15 + set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounterValueType | LiveMapValueType) value) => io // RTPO15 remove(String key) => io // RTPO16 increment(Number amount?) => io // RTPO17 decrement(Number amount?) => io // RTPO18 @@ -975,7 +1044,7 @@ Types and their properties/methods are public and exposed to users by default. A size() -> Number? // RTINS9 compact() -> Object? // RTINS10 compactJson() -> Object? // RTINS11 - set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap) value) => io // RTINS12 + set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounterValueType | LiveMapValueType) value) => io // RTINS12 remove(String key) => io // RTINS13 increment(Number amount?) => io // RTINS14 decrement(Number amount?) => io // RTINS15 From fb521a852656e4950e2f8a3203f085f33225685a Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 24 Feb 2026 13:04:55 +0000 Subject: [PATCH 03/43] Subscriptions for PathObject and Instance --- specifications/features.md | 9 ++++ specifications/objects-features.md | 82 +++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/specifications/features.md b/specifications/features.md index 113232331..9752832b6 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1870,6 +1870,12 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(REX2b1)` Should be written in reverse domain name notation - `(REX2b2)` Types beginning with `com.ably.` are reserved +#### Subscription + +- `(SUB1)` A `Subscription` represents a registration for receiving events from a subscribe operation +- `(SUB2)` The `Subscription` object has the following method: + - `(SUB2a)` `unsubscribe` - deregisters the listener that was registered by the corresponding `subscribe` call. Once `unsubscribe` called, the listener must not be called for any subsequent events + ### Option types {#options} #### ClientOptions @@ -2924,6 +2930,9 @@ Each type, method, and attribute is labelled with the name of one or more clause description: string? // TM2s4 metadata: Dict? //TM2s5 + interface Subscription: // SUB* + unsubscribe() // SUB2a + ## Old specs Use the version navigation to view older versions. References to diffs for each version are maintained below: diff --git a/specifications/objects-features.md b/specifications/objects-features.md index a66dddfc5..0078c8479 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -288,6 +288,14 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO22)` `ObjectsOperationSource` is an internal enum describing the source of an operation being applied: - `(RTO22a)` `LOCAL` - an operation that originated locally, being applied upon receipt of the `ACK` from Realtime - `(RTO22b)` `CHANNEL` - an operation received over a Realtime channel +- `(RTO24)` Internal `PathObjectSubscriptionRegister` - manages path-based subscriptions for `PathObject#subscribe` ([RTPO19](#RTPO19)) + - `(RTO24a)` The `RealtimeObject` instance maintains a single `PathObjectSubscriptionRegister` that manages all path-based subscriptions for the channel + - `(RTO24b)` When a `LiveObject` in the `ObjectsPool` emits a `LiveObjectUpdate` (per [RTLO4b4](#RTLO4b4)), the `PathObjectSubscriptionRegister` must determine which subscriptions should be notified: + - `(RTO24b1)` Determine the paths in the LiveObjects tree at which the updated `LiveObject` is located + - `(RTO24b2)` For each registered subscription, check whether the event path starts with (or equals) the subscription's path + - `(RTO24b3)` If the event path matches, apply depth filtering: the event is dispatched to the subscription if the number of path segments from the subscription path to the event path plus 1 does not exceed the subscription's `depth` option (or if `depth` is undefined). Formally, the event is dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth` + - `(RTO24b4)` Create a `PathObjectSubscriptionEvent` with a `PathObject` pointing to the event path and the `ObjectMessage` that caused the change, and call the subscription's listener + - `(RTO24b5)` If a listener throws an error, the error must be caught and logged without affecting the dispatch to other subscriptions ### LiveObject @@ -315,9 +323,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4b4c)` When a `LiveObjectUpdate` is emitted: - `(RTLO4b4c1)` If `LiveObjectUpdate` is indicated to be a no-op, do nothing - `(RTLO4b4c2)` Otherwise, the registered listener is called with the `LiveObjectUpdate` object - - `(RTLO4b5)` The client library may return a subscription object (or the idiomatic equivalent for the language) as a result of this operation: - - `(RTLO4b5a)` The subscription object includes an `unsubscribe` function - - `(RTLO4b5b)` Calling `unsubscribe` deregisters the listener previously registered by the user via the corresponding `subscribe` call + - `(RTLO4b5)` This clause has been replaced by [RTLO4b7](#RTLO4b7) + - `(RTLO4b5a)` This clause has been replaced by [RTLO4b7](#RTLO4b7) + - `(RTLO4b5b)` This clause has been replaced by [RTLO4b7](#RTLO4b7) + - `(RTLO4b7)` Returns a [`Subscription`](../features#SUB1) object - `(RTLO4b6)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status - `(RTLO4c)` public `unsubscribe` - unsubscribes a previously registered listener - `(RTLO4c1)` This operation does not require any specific channel modes to be granted, nor does it require the channel to be in a specific state @@ -885,6 +894,31 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO18b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) - `(RTPO18c)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#decrement` ([RTLC13](#RTLC13)) with the provided `amount` - `(RTPO18d)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 +- `(RTPO19)` `PathObject#subscribe` function: + - `(RTPO19a)` Expects the following arguments: + - `(RTPO19a1)` `listener` - a callback function that receives a `PathObjectSubscriptionEvent` ([RTPO19d](#RTPO19d)) when a change occurs at or below this path + - `(RTPO19a2)` `options` `PathObjectSubscriptionOptions` (optional) - subscription options + - `(RTPO19b)` `PathObjectSubscriptionOptions` has the following properties: + - `(RTPO19b1)` `depth` `Number` (optional) - controls how many levels deep in the subtree changes trigger the listener: + - `(RTPO19b1a)` If undefined (default), the subscription receives events for changes at any depth below the subscribed path + - `(RTPO19b1b)` If `depth` is 1, only changes to the object at the exact subscribed path trigger the listener + - `(RTPO19b1c)` If `depth` is `n`, changes up to `n - 1` levels of children below the subscribed path trigger the listener + - `(RTPO19b1d)` If `depth` is provided and is not a positive integer, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003 + - `(RTPO19c)` Returns a [`Subscription`](../features#SUB1) object + - `(RTPO19d)` The listener receives a `PathObjectSubscriptionEvent` object with: + - `(RTPO19d1)` `object` - a `PathObject` pointing to the path where the change occurred + - `(RTPO19d2)` `message` `ObjectMessage` (optional) - the `ObjectMessage` that caused the change + - `(RTPO19e)` The subscription is path-based: it follows the path, not a specific object. If the object at the path changes identity (e.g. via a `MAP_SET` operation replacing it), the subscription continues to deliver events for the new object at that path + - `(RTPO19f)` Events at child paths bubble up to the subscription, subject to depth filtering. For example, a subscription at path `a.b` receives events for changes at `a.b`, `a.b.c`, `a.b.c.d`, etc., depending on the configured depth. The dispatch rules are described in [RTO24b](#RTO24b) + - `(RTPO19g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status +- `(RTPO20)` `PathObject#unsubscribe` function: + - `(RTPO20a)` Accepts a `listener` argument and deregisters it from receiving further events for this `PathObject`'s path + - `(RTPO20b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status +- `(RTPO21)` The client library should provide a method that allows consuming subscription events as a stream or iterable, rather than via a callback. A suggested name for this method is `subscribeIterator`: + - `(RTPO21a)` Expects the following arguments: + - `(RTPO21a1)` `options` `PathObjectSubscriptionOptions` (optional) - same options as `PathObject#subscribe` ([RTPO19b](#RTPO19b)) + - `(RTPO21b)` Returns a stream or iterable that yields `PathObjectSubscriptionEvent` objects, using the idiomatic construct for the language (e.g. async iterators, channels, flows, or async sequences) + - `(RTPO21c)` Internally wraps `PathObject#subscribe` ([RTPO19](#RTPO19)), converting the callback-based subscription into the appropriate streaming or iterable pattern ### Instance @@ -941,6 +975,24 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS15a1)` `amount` `Number` (optional) - the amount by which to decrement the counter value. Defaults to 1 - `(RTINS15b)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#decrement` ([RTLC13](#RTLC13)) with the provided `amount` - `(RTINS15c)` If the wrapped value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 +- `(RTINS16)` `Instance#subscribe` function: + - `(RTINS16a)` Expects the following arguments: + - `(RTINS16a1)` `listener` - a callback function that receives an `InstanceSubscriptionEvent` ([RTINS16d](#RTINS16d)) when the wrapped object is updated + - `(RTINS16b)` If the wrapped value is not a `LiveObject` (i.e. it is a primitive), the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that subscribe is not supported for primitive values + - `(RTINS16c)` Subscribes to data updates on the underlying `LiveObject` using `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) + - `(RTINS16d)` The listener receives an `InstanceSubscriptionEvent` object with: + - `(RTINS16d1)` `object` - the `Instance` representing the updated object + - `(RTINS16d2)` `message` `ObjectMessage` (optional) - the `ObjectMessage` that caused the change + - `(RTINS16e)` Returns a [`Subscription`](../features#SUB1) object + - `(RTINS16f)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the tree + - `(RTINS16g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status +- `(RTINS17)` `Instance#unsubscribe` function: + - `(RTINS17a)` Accepts a `listener` argument and deregisters it from receiving further events using `LiveObject#unsubscribe` ([RTLO4c](#RTLO4c)) + - `(RTINS17b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status +- `(RTINS18)` The client library should provide a method that allows consuming subscription events as a stream or iterable, rather than via a callback. A suggested name for this method is `subscribeIterator`: + - `(RTINS18a)` If the wrapped value is not a `LiveObject`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + - `(RTINS18b)` Returns a stream or iterable that yields `InstanceSubscriptionEvent` objects, using the idiomatic construct for the language (e.g. async iterators, channels, flows, or async sequences) + - `(RTINS18c)` Internally wraps `Instance#subscribe` ([RTINS16](#RTINS16)), converting the callback-based subscription into the appropriate streaming or iterable pattern ## Interface Definition {#idl} @@ -978,13 +1030,10 @@ Types and their properties/methods are public and exposed to users by default. A tombstonedAt: Time? // RTLO3e canApplyOperation(ObjectMessage) -> Boolean // RTLO4a tombstone(ObjectMessage) // RTLO4e - subscribe((LiveObjectUpdate) ->) -> LiveObjectSubscription // RTLO4b + subscribe((LiveObjectUpdate) ->) -> Subscription // RTLO4b unsubscribe((LiveObjectUpdate) ->) // RTLO4c - interface LiveObjectSubscription: // RTLO4b5 - unsubscribe() // RTLO4b5a - - interface LiveObjectUpdate: // RTLO4b4, internal + interface LiveObjectUpdate: // RTLO4b4 update: Object // RTLO4b4a noop: Boolean // RTLO4b4b @@ -1017,6 +1066,17 @@ Types and their properties/methods are public and exposed to users by default. A class LiveMapValueType: // RTLMV* // created via LiveMap.create(), RTLMV3 + interface PathObjectSubscriptionEvent: // RTPO19d + object: PathObject // RTPO19d1 + message: ObjectMessage? // RTPO19d2 + + interface PathObjectSubscriptionOptions: // RTPO19b + depth: Number? // RTPO19b1 + + interface InstanceSubscriptionEvent: // RTINS16d + object: Instance // RTINS16d1 + message: ObjectMessage? // RTINS16d2 + class PathObject: // RTPO* path() -> String // RTPO4 get(String key) -> PathObject // RTPO5 @@ -1033,6 +1093,9 @@ Types and their properties/methods are public and exposed to users by default. A remove(String key) => io // RTPO16 increment(Number amount?) => io // RTPO17 decrement(Number amount?) => io // RTPO18 + subscribe((PathObjectSubscriptionEvent) -> listener, PathObjectSubscriptionOptions? options) -> Subscription // RTPO19 + unsubscribe((PathObjectSubscriptionEvent) -> listener) // RTPO20 + subscribeIterator(PathObjectSubscriptionOptions? options) -> Stream // RTPO21 class Instance: // RTINS* id: String? // RTINS3 @@ -1048,3 +1111,6 @@ Types and their properties/methods are public and exposed to users by default. A remove(String key) => io // RTINS13 increment(Number amount?) => io // RTINS14 decrement(Number amount?) => io // RTINS15 + subscribe((InstanceSubscriptionEvent) -> listener) -> Subscription // RTINS16 + unsubscribe((InstanceSubscriptionEvent) -> listener) // RTINS17 + subscribeIterator() -> Stream // RTINS18 From e9e5aa9ffa6c667f7ac54f0afef65f2359186fe7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 15:14:46 -0300 Subject: [PATCH 04/43] Mark `LiveObjectUpdate` as internal in IDL The `LiveCounterUpdate` and `LiveMapUpdate` subtypes already have the `internal` marker; the parent `LiveObjectUpdate` interface was missing it. Add it for consistency, reflecting that `LiveObject#subscribe` and its emitted update objects are not part of the public API (confirmed against ably-js, where these types live only in the plugin internals and are not exported from `liveobjects.d.ts`). Addresses [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3259453973 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 0078c8479..3dc4543d6 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -1033,7 +1033,7 @@ Types and their properties/methods are public and exposed to users by default. A subscribe((LiveObjectUpdate) ->) -> Subscription // RTLO4b unsubscribe((LiveObjectUpdate) ->) // RTLO4c - interface LiveObjectUpdate: // RTLO4b4 + interface LiveObjectUpdate: // RTLO4b4, internal update: Object // RTLO4b4a noop: Boolean // RTLO4b4b From 848147c50694854e4664f109f3ff905793e8cda6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 15:19:14 -0300 Subject: [PATCH 05/43] Use arrays, not iterators, for PathObject and Instance getters The `entries`, `keys`, and `values` methods on `PathObject` and `Instance` were previously described as returning iterators (`Iterator<...>` in the IDL, "iterator yielding..." in the prose). "Iterator" is not a term we use elsewhere in the spec, and the array form is easier to reason about and consistent with how `LiveMap#entries` ([RTLM11](#RTLM11)) etc. are already specified. SDKs remain free to use a platform-idiomatic equivalent in their public surface (e.g. a JS iterator). Addresses [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3261238756 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 3dc4543d6..1adf66895 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -843,13 +843,13 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO8d)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) - `(RTPO9)` `PathObject#entries` function: - `(RTPO9a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO9b)` If the resolved value is a `LiveMap`, returns an iterator yielding `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` + - `(RTPO9b)` If the resolved value is a `LiveMap`, returns an array of `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` - `(RTPO9c)` Only non-tombstoned entries are included, following the same rules as `LiveMap#entries` ([RTLM11](#RTLM11)) - - `(RTPO9d)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty iterator + - `(RTPO9d)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array - `(RTPO10)` `PathObject#keys` function: - - `(RTPO10a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that it yields only the keys + - `(RTPO10a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that the array contains only the keys - `(RTPO11)` `PathObject#values` function: - - `(RTPO11a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that it yields only the `PathObject` values + - `(RTPO11a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that the array contains only the `PathObject` values - `(RTPO12)` `PathObject#size` function: - `(RTPO12a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - `(RTPO12b)` If the resolved value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10)) @@ -941,12 +941,12 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS5b)` If the wrapped value is a `LiveMap`, looks up the value at `key` using `LiveMap#get` ([RTLM5](#RTLM5)) and returns a new `Instance` wrapping the result. If the result is undefined/null, returns undefined/null - `(RTINS5c)` If the wrapped value is not a `LiveMap`, returns undefined/null - `(RTINS6)` `Instance#entries` function: - - `(RTINS6a)` If the wrapped value is a `LiveMap`, returns an iterator yielding `[key, Instance]` pairs, where each `Instance` wraps the corresponding entry value from `LiveMap#entries` ([RTLM11](#RTLM11)) - - `(RTINS6b)` If the wrapped value is not a `LiveMap`, returns an empty iterator + - `(RTINS6a)` If the wrapped value is a `LiveMap`, returns an array of `[key, Instance]` pairs, where each `Instance` wraps the corresponding entry value from `LiveMap#entries` ([RTLM11](#RTLM11)) + - `(RTINS6b)` If the wrapped value is not a `LiveMap`, returns an empty array - `(RTINS7)` `Instance#keys` function: - - `(RTINS7a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that it yields only the keys + - `(RTINS7a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that the array contains only the keys - `(RTINS8)` `Instance#values` function: - - `(RTINS8a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that it yields only the `Instance` values + - `(RTINS8a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that the array contains only the `Instance` values - `(RTINS9)` `Instance#size` function: - `(RTINS9a)` If the wrapped value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10)) - `(RTINS9b)` If the wrapped value is not a `LiveMap`, returns undefined/null @@ -1083,9 +1083,9 @@ Types and their properties/methods are public and exposed to users by default. A at(String path) -> PathObject // RTPO6 value() -> (Boolean | Binary | Number | String | JsonArray | JsonObject)? // RTPO7 instance() -> Instance? // RTPO8 - entries() -> Iterator<[String, PathObject]> // RTPO9 - keys() -> Iterator // RTPO10 - values() -> Iterator // RTPO11 + entries() -> [String, PathObject][] // RTPO9 + keys() -> String[] // RTPO10 + values() -> PathObject[] // RTPO11 size() -> Number? // RTPO12 compact() -> Object? // RTPO13 compactJson() -> Object? // RTPO14 @@ -1101,9 +1101,9 @@ Types and their properties/methods are public and exposed to users by default. A id: String? // RTINS3 value() -> (Boolean | Binary | Number | String | JsonArray | JsonObject)? // RTINS4 get(String key) -> Instance? // RTINS5 - entries() -> Iterator<[String, Instance]> // RTINS6 - keys() -> Iterator // RTINS7 - values() -> Iterator // RTINS8 + entries() -> [String, Instance][] // RTINS6 + keys() -> String[] // RTINS7 + values() -> Instance[] // RTINS8 size() -> Number? // RTINS9 compact() -> Object? // RTINS10 compactJson() -> Object? // RTINS11 From beb68273e86affd9be42adc5663cf439d9264861 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 15:41:03 -0300 Subject: [PATCH 06/43] Use explicit delegation for PathObject and Instance methods Several PathObject and Instance methods previously described their behaviour by restating the underlying LiveMap or LiveCounter semantics ("returns the number of non-tombstoned entries, equivalent to LiveMap#size") or by chaining through a sibling method ("behaves identically to PathObject#entries except the array contains only the keys"). Both forms duplicate or obscure the fact that the SDK is just delegating to the LiveMap/LiveCounter spec point. Rewrite each as an explicit delegation, so the LiveMap/LiveCounter spec point remains the single source of truth for the semantics (tombstone handling, ordering, etc.): - RTPO7b, RTINS4a: PathObject#value / Instance#value for LiveCounter delegate to LiveCounter#value (consistency fold-in, no specific comment). - RTPO9 (PathObject#entries): collapse the previous b/c/d into b (delegate to LiveMap#keys and build [key, PathObject] pairs) plus c (empty array on failure). LiveMap#keys rather than #entries is correct because the PathObject is lazy and does not need resolved values. - RTPO10, RTPO11 (PathObject#keys / #values): resolve and delegate directly to LiveMap#keys instead of chaining through PathObject#entries. RTPO11 still wraps each key in a PathObject. - RTPO12b, RTINS9a (#size): delegate to LiveMap#size. - RTINS6a (Instance#entries): rephrase to lead with "delegates to LiveMap#entries" and wrap each value in an Instance. - RTINS7, RTINS8 (Instance#keys / #values): delegate directly to LiveMap#keys / #values; RTINS8 wraps each value in an Instance. compact / compactJson (RTPO13/14, RTINS10/11) are not touched here; they will be addressed separately because they require a new LiveMap#compact spec point. Addresses [1], [2], [3] (and reply [4]), [5]. [1] https://github.com/ably/specification/pull/427#discussion_r3260769728 [2] https://github.com/ably/specification/pull/427#discussion_r3260776863 [3] https://github.com/ably/specification/pull/427#discussion_r3260486938 [4] https://github.com/ably/specification/pull/427#discussion_r3260743558 [5] https://github.com/ably/specification/pull/427#discussion_r3260480987 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 1adf66895..5a439e675 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -832,7 +832,7 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO6d)` This is a convenience for chaining multiple `PathObject#get` calls. For example, `pathObject.at("a.b.c")` is equivalent to `pathObject.get("a").get("b").get("c")` - `(RTPO7)` `PathObject#value` function: - `(RTPO7a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO7b)` If the resolved value is a `LiveCounter`, returns its current numeric value (equivalent to `LiveCounter#value`, see [RTLC5](#RTLC5)) + - `(RTPO7b)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#value` ([RTLC5](#RTLC5)) - `(RTPO7c)` If the resolved value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns the value directly - `(RTPO7d)` If the resolved value is a `LiveMap`, returns undefined/null - `(RTPO7e)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) @@ -843,16 +843,19 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO8d)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) - `(RTPO9)` `PathObject#entries` function: - `(RTPO9a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO9b)` If the resolved value is a `LiveMap`, returns an array of `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` - - `(RTPO9c)` Only non-tombstoned entries are included, following the same rules as `LiveMap#entries` ([RTLM11](#RTLM11)) - - `(RTPO9d)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array + - `(RTPO9b)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) and returns an array of `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` + - `(RTPO9c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array - `(RTPO10)` `PathObject#keys` function: - - `(RTPO10a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that the array contains only the keys + - `(RTPO10a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO10b)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) + - `(RTPO10c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array - `(RTPO11)` `PathObject#values` function: - - `(RTPO11a)` Behaves identically to `PathObject#entries` ([RTPO9](#RTPO9)) except that the array contains only the `PathObject` values + - `(RTPO11a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO11b)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) and returns an array of `PathObject`s, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` + - `(RTPO11c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array - `(RTPO12)` `PathObject#size` function: - `(RTPO12a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO12b)` If the resolved value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10)) + - `(RTPO12b)` If the resolved value is a `LiveMap`, delegates to `LiveMap#size` ([RTLM10](#RTLM10)) - `(RTPO12c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns undefined/null - `(RTPO13)` `PathObject#compact` function: - `(RTPO13a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) @@ -932,7 +935,7 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS3a)` If the wrapped value is a `LiveObject`, returns the `objectId` of that object - `(RTINS3b)` If the wrapped value is a primitive, returns undefined/null - `(RTINS4)` `Instance#value` function: - - `(RTINS4a)` If the wrapped value is a `LiveCounter`, returns its current numeric value (equivalent to `LiveCounter#value`, see [RTLC5](#RTLC5)) + - `(RTINS4a)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#value` ([RTLC5](#RTLC5)) - `(RTINS4b)` If the wrapped value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns the value directly - `(RTINS4c)` If the wrapped value is a `LiveMap`, returns undefined/null - `(RTINS5)` `Instance#get` function: @@ -941,14 +944,16 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS5b)` If the wrapped value is a `LiveMap`, looks up the value at `key` using `LiveMap#get` ([RTLM5](#RTLM5)) and returns a new `Instance` wrapping the result. If the result is undefined/null, returns undefined/null - `(RTINS5c)` If the wrapped value is not a `LiveMap`, returns undefined/null - `(RTINS6)` `Instance#entries` function: - - `(RTINS6a)` If the wrapped value is a `LiveMap`, returns an array of `[key, Instance]` pairs, where each `Instance` wraps the corresponding entry value from `LiveMap#entries` ([RTLM11](#RTLM11)) + - `(RTINS6a)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#entries` ([RTLM11](#RTLM11)) and returns an array of `[key, Instance]` pairs, where each `Instance` wraps the corresponding value - `(RTINS6b)` If the wrapped value is not a `LiveMap`, returns an empty array - `(RTINS7)` `Instance#keys` function: - - `(RTINS7a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that the array contains only the keys + - `(RTINS7a)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) + - `(RTINS7b)` If the wrapped value is not a `LiveMap`, returns an empty array - `(RTINS8)` `Instance#values` function: - - `(RTINS8a)` Behaves identically to `Instance#entries` ([RTINS6](#RTINS6)) except that the array contains only the `Instance` values + - `(RTINS8a)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#values` ([RTLM13](#RTLM13)) and returns an array of `Instance`s, where each `Instance` wraps the corresponding value + - `(RTINS8b)` If the wrapped value is not a `LiveMap`, returns an empty array - `(RTINS9)` `Instance#size` function: - - `(RTINS9a)` If the wrapped value is a `LiveMap`, returns the number of non-tombstoned entries (equivalent to `LiveMap#size`, see [RTLM10](#RTLM10)) + - `(RTINS9a)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#size` ([RTLM10](#RTLM10)) - `(RTINS9b)` If the wrapped value is not a `LiveMap`, returns undefined/null - `(RTINS10)` `Instance#compact` function: - `(RTINS10a)` Behaves identically to `PathObject#compact` ([RTPO13](#RTPO13)), but operates on the wrapped value directly instead of resolving a path From 12f192fd1921281ffa2370a3991c4a95a2f76c94 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 16:22:17 -0300 Subject: [PATCH 07/43] Use "evaluate"/"evaluation" instead of "consume"/"consumption" for value types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Consume" suggested a one-shot procedure, which is misleading for an immutable value type that could in principle be evaluated multiple times (e.g. passed to several mutation calls). "Evaluate" captures the deferred-validation semantics of the value type (per RTLCV3c / RTLMV3c, validation is deferred to this procedure, much like evaluating a lazy expression) without the one-shot implication. The unrelated "consuming subscription events as a stream" usages in RTPO21 and RTINS18 are left alone — that is the standard consumer-of-stream sense, which doesn't have the same problem. Addresses [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3259001228 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 5a439e675..5d316ab7d 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -543,7 +543,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM20e7)` Set `ObjectMessage.operation.mapSet.value` depending on the type of the provided `value`: - `(RTLM20e7a)` This clause has been replaced by [RTLM20e7g](#RTLM20e7g). - `(RTLM20e7g)` If the `value` is of type `LiveCounterValueType` or `LiveMapValueType`: - - `(RTLM20e7g1)` Consume the value type per [RTLCV4](#RTLCV4) or [RTLMV4](#RTLMV4) respectively to generate `*_CREATE` `ObjectMessages`. Collect all generated `ObjectMessages` + - `(RTLM20e7g1)` Evaluate the value type per [RTLCV4](#RTLCV4) or [RTLMV4](#RTLMV4) respectively to generate `*_CREATE` `ObjectMessages`. Collect all generated `ObjectMessages` - `(RTLM20e7g2)` Set `ObjectMessage.operation.mapSet.value.objectId` to the `objectId` from the outermost `*_CREATE` `ObjectMessage` - `(RTLM20e7b)` If the `value` is of type `JsonArray` or `JsonObject`, set `ObjectMessage.operation.mapSet.value.json` to that value - `(RTLM20e7c)` If the `value` is of type `String`, set `ObjectMessage.operation.mapSet.value.string` to that value @@ -725,7 +725,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. ### LiveCounterValueType -A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCounter` object. It stores the desired initial count value and is consumed when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in `LiveMap.create` ([RTLMV3](#RTLMV3)). +A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCounter` object. It stores the desired initial count value and is evaluated when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in `LiveMap.create` ([RTLMV3](#RTLMV3)). - `(RTLCV1)` `LiveCounterValueType` is an immutable value type representing the intent to create a new `LiveCounter` with a specific initial count - `(RTLCV2)` `LiveCounterValueType` has the following internal properties: @@ -734,9 +734,9 @@ A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCount - `(RTLCV3a)` Expects the following arguments: - `(RTLCV3a1)` `initialCount` `Number` (optional) - the initial count for the new `LiveCounter` object. Defaults to 0 - `(RTLCV3b)` Returns a new `LiveCounterValueType` instance with the internal `count` set to the provided `initialCount` (or 0 if omitted) - - `(RTLCV3c)` No input validation is performed at creation time. Validation is deferred to the consumption procedure ([RTLCV4](#RTLCV4)) + - `(RTLCV3c)` No input validation is performed at creation time. Validation is deferred to the evaluation procedure ([RTLCV4](#RTLCV4)) - `(RTLCV3d)` The returned `LiveCounterValueType` is immutable and must not be modified after creation -- `(RTLCV4)` Internal consumption procedure - when a `LiveCounterValueType` is consumed by a mutation method (e.g. `LiveMap#set` or as an entry value during `LiveMapValueType` consumption per [RTLMV4](#RTLMV4)), a `COUNTER_CREATE` `ObjectMessage` is generated as follows: +- `(RTLCV4)` Internal evaluation procedure - when a `LiveCounterValueType` is evaluated by a mutation method (e.g. `LiveMap#set` or as an entry value during `LiveMapValueType` evaluation per [RTLMV4](#RTLMV4)), a `COUNTER_CREATE` `ObjectMessage` is generated as follows: - `(RTLCV4a)` If the internal `count` is not undefined and (is not of type `Number` or is not a finite number), the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that the counter value must be a valid number - `(RTLCV4b)` Create a `CounterCreate` object: - `(RTLCV4b1)` Set `CounterCreate.count` to the internal `count` value, or to 0 if undefined @@ -754,7 +754,7 @@ A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCount ### LiveMapValueType -A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` object. It stores the desired initial entries and is consumed when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in another `LiveMap.create` ([RTLMV3](#RTLMV3)) call. Supports arbitrarily deep nesting of `LiveMapValueType` and `LiveCounterValueType` values within entries. +A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` object. It stores the desired initial entries and is evaluated when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in another `LiveMap.create` ([RTLMV3](#RTLMV3)) call. Supports arbitrarily deep nesting of `LiveMapValueType` and `LiveCounterValueType` values within entries. - `(RTLMV1)` `LiveMapValueType` is an immutable value type representing the intent to create a new `LiveMap` with specific initial entries - `(RTLMV2)` `LiveMapValueType` has the following internal properties: @@ -763,15 +763,15 @@ A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` obje - `(RTLMV3a)` Expects the following arguments: - `(RTLMV3a1)` `entries` `Dict` (optional) - the initial entries for the new `LiveMap` object - `(RTLMV3b)` Returns a new `LiveMapValueType` instance with the internal `entries` set to the provided `entries` (or undefined if omitted) - - `(RTLMV3c)` No input validation is performed at creation time. Validation is deferred to the consumption procedure ([RTLMV4](#RTLMV4)) + - `(RTLMV3c)` No input validation is performed at creation time. Validation is deferred to the evaluation procedure ([RTLMV4](#RTLMV4)) - `(RTLMV3d)` The returned `LiveMapValueType` is immutable and must not be modified after creation -- `(RTLMV4)` Internal consumption procedure - when a `LiveMapValueType` is consumed by a mutation method (e.g. `LiveMap#set` or as an entry value during another `LiveMapValueType` consumption), `ObjectMessages` are generated as follows: +- `(RTLMV4)` Internal evaluation procedure - when a `LiveMapValueType` is evaluated by a mutation method (e.g. `LiveMap#set` or as an entry value during another `LiveMapValueType` evaluation), `ObjectMessages` are generated as follows: - `(RTLMV4a)` If the internal `entries` is not undefined and (is null or is not of type `Dict`), the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that entries must be a `Dict` - `(RTLMV4b)` If any of the keys in the internal `entries` are not of type `String`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that keys must be `String` - `(RTLMV4c)` If any of the values in the internal `entries` are not of an expected type, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40013, indicating that such data type is unsupported - `(RTLMV4d)` Build entries for the `MapCreate` object. For each key-value pair in the internal `entries` (if present), create an `ObjectsMapEntry` for the value: - - `(RTLMV4d1)` If the value is of type `LiveCounterValueType`, consume it per [RTLCV4](#RTLCV4) to generate a `COUNTER_CREATE` `ObjectMessage`. Collect the generated `ObjectMessage` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the `ObjectMessage` - - `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively consume it per [RTLMV4](#RTLMV4) to generate `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the outermost `MAP_CREATE` `ObjectMessage` + - `(RTLMV4d1)` If the value is of type `LiveCounterValueType`, evaluate it per [RTLCV4](#RTLCV4) to generate a `COUNTER_CREATE` `ObjectMessage`. Collect the generated `ObjectMessage` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the `ObjectMessage` + - `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively evaluate it per [RTLMV4](#RTLMV4) to generate `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the outermost `MAP_CREATE` `ObjectMessage` - `(RTLMV4d3)` If the value is of type `JsonArray` or `JsonObject`, set `ObjectsMapEntry.data.json` to that value - `(RTLMV4d4)` If the value is of type `String`, set `ObjectsMapEntry.data.string` to that value - `(RTLMV4d5)` If the value is of type `Number`, set `ObjectsMapEntry.data.number` to that value @@ -792,7 +792,7 @@ A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` obje - `(RTLMV4j3)` `ObjectMessage.operation.mapCreateWithObjectId.nonce` set to the nonce from [RTLMV4g](#RTLMV4g) - `(RTLMV4j4)` `ObjectMessage.operation.mapCreateWithObjectId.initialValue` set to the JSON string from [RTLMV4f](#RTLMV4f) - `(RTLMV4j5)` The client library must retain the `MapCreate` object from [RTLMV4e](#RTLMV4e) alongside the `MapCreateWithObjectId`. It is the operation from which the `MapCreateWithObjectId` was derived, and is needed for message size calculation ([OOP4h2](../features#OOP4h2)) and local application of the operation ([RTLM23](#RTLM23)). This `MapCreate` is for local use only and must not be sent over the wire. - - `(RTLMV4k)` Return an ordered array containing all `ObjectMessages` collected from nested value type consumptions in [RTLMV4d](#RTLMV4d) (in depth-first order), followed by the `MAP_CREATE` `ObjectMessage` from [RTLMV4j](#RTLMV4j) + - `(RTLMV4k)` Return an ordered array containing all `ObjectMessages` collected from nested value type evaluations in [RTLMV4d](#RTLMV4d) (in depth-first order), followed by the `MAP_CREATE` `ObjectMessage` from [RTLMV4j](#RTLMV4j) ### PathObject From 2c3216998e37a7e8da55758196e3e70fd450ad19 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 16:48:09 -0300 Subject: [PATCH 08/43] Fix missing "is" in SUB2a "Once `unsubscribe` called" was missing the verb. Reads as "Once `unsubscribe` is called" now. Addresses [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3234006822 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifications/features.md b/specifications/features.md index 9752832b6..9ccb41f0b 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1874,7 +1874,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(SUB1)` A `Subscription` represents a registration for receiving events from a subscribe operation - `(SUB2)` The `Subscription` object has the following method: - - `(SUB2a)` `unsubscribe` - deregisters the listener that was registered by the corresponding `subscribe` call. Once `unsubscribe` called, the listener must not be called for any subsequent events + - `(SUB2a)` `unsubscribe` - deregisters the listener that was registered by the corresponding `subscribe` call. Once `unsubscribe` is called, the listener must not be called for any subsequent events ### Option types {#options} From ab6a7a5a5f9cc93051e4ccf65309823be6783972 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 17:07:56 -0300 Subject: [PATCH 09/43] Clarify InstanceSubscriptionEvent.object wording RTINS16d1 previously read "the `Instance` representing the updated object", which was vague. Reword to "an `Instance` wrapping the underlying `LiveObject`", reusing the "underlying `LiveObject`" terminology already used in RTINS16c. The new wording deliberately uses "an `Instance`" rather than "the `Instance` on which `#subscribe` was called", so that the spec does not mandate strict reference identity between the subscribed `Instance` and the one delivered in the event. ably-js currently reuses the subscriber's own `Instance` (`instance.ts:218-223` passes `object: this`), which has the side effect of pinning that `Instance` for the lifetime of the subscription. Implementations are free to make that trade-off either way: pin for identity, or allocate a fresh `Instance` per event so the subscriber's reference can be collected independently. Addresses [1] and its follow-up [2]. [1] https://github.com/ably/specification/pull/427#discussion_r3260535507 [2] https://github.com/ably/specification/pull/427#discussion_r3261075871 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 5d316ab7d..31c9ac78f 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -986,7 +986,7 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS16b)` If the wrapped value is not a `LiveObject` (i.e. it is a primitive), the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that subscribe is not supported for primitive values - `(RTINS16c)` Subscribes to data updates on the underlying `LiveObject` using `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) - `(RTINS16d)` The listener receives an `InstanceSubscriptionEvent` object with: - - `(RTINS16d1)` `object` - the `Instance` representing the updated object + - `(RTINS16d1)` `object` - an `Instance` wrapping the underlying `LiveObject` - `(RTINS16d2)` `message` `ObjectMessage` (optional) - the `ObjectMessage` that caused the change - `(RTINS16e)` Returns a [`Subscription`](../features#SUB1) object - `(RTINS16f)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the tree From 9b721d246156487529e719cc1a7e9529805ebd0a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 08:44:09 -0300 Subject: [PATCH 10/43] Make LiveMap/LiveCounter/LiveObject fully internal in IDL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, `LiveCounter` was not marked `internal` in the IDL even though it extends the internal `LiveObject` — flagged by Copilot as non-implementable in some languages (e.g. C# requires a base type to be at least as accessible as the derived public type). Mark `LiveCounter` `internal` to match `LiveObject` and `LiveMap`. Drop the redundant per-member `, internal` markers from `LiveCounter` and `LiveMap` IDL bodies now that the enclosing class carries the marker (matching `LiveObject`'s existing convention). Move `static create(...)` off `LiveCounter` and `LiveMap` and onto `LiveCounterValueType` and `LiveMapValueType`. The public-facing surface is now `PathObject`, `Instance`, and the two value types; `LiveMap`/`LiveCounter`/`LiveObject` are purely internal implementation classes. This honestly reflects what ably-js does — its public `LiveMap`/`LiveCounter` (in `liveobjects.d.ts`) are branded interfaces that happen to share their name with the internal implementation classes, a JS-specific trick the spec doesn't need to replicate. Prose updated to reference `LiveCounterValueType.create` / `LiveMapValueType.create` (previously `LiveCounter.create` / `LiveMap.create`). Spec IDs (RTLCV3, RTLMV3) unchanged. The `ValueType` naming itself is not addressed here; we want to merge this PR rather than get lost in bikeshedding names, and will revisit before stabilising Swift/Kotlin. Addresses [1], [2], [3]. [1] https://github.com/ably/specification/pull/427#discussion_r3234006786 [2] https://github.com/ably/specification/pull/427#discussion_r3258929102 [3] https://github.com/ably/specification/pull/427#discussion_r3261405076 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 38 ++++++++++++++---------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 31c9ac78f..1a018272e 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -725,12 +725,12 @@ Objects feature enables clients to store shared data as "objects" on a channel. ### LiveCounterValueType -A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCounter` object. It stores the desired initial count value and is evaluated when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in `LiveMap.create` ([RTLMV3](#RTLMV3)). +A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCounter` object. It stores the desired initial count value and is evaluated when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in `LiveMapValueType.create` ([RTLMV3](#RTLMV3)). - `(RTLCV1)` `LiveCounterValueType` is an immutable value type representing the intent to create a new `LiveCounter` with a specific initial count - `(RTLCV2)` `LiveCounterValueType` has the following internal properties: - `(RTLCV2a)` `count` `Number` - the initial count value for the `LiveCounter` to be created -- `(RTLCV3)` `LiveCounter.create` static factory function: +- `(RTLCV3)` `LiveCounterValueType.create` static factory function: - `(RTLCV3a)` Expects the following arguments: - `(RTLCV3a1)` `initialCount` `Number` (optional) - the initial count for the new `LiveCounter` object. Defaults to 0 - `(RTLCV3b)` Returns a new `LiveCounterValueType` instance with the internal `count` set to the provided `initialCount` (or 0 if omitted) @@ -754,12 +754,12 @@ A `LiveCounterValueType` is an immutable blueprint for creating a new `LiveCount ### LiveMapValueType -A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` object. It stores the desired initial entries and is evaluated when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in another `LiveMap.create` ([RTLMV3](#RTLMV3)) call. Supports arbitrarily deep nesting of `LiveMapValueType` and `LiveCounterValueType` values within entries. +A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` object. It stores the desired initial entries and is evaluated when passed to a mutation method such as `LiveMap#set` ([RTLM20](#RTLM20)) or as an entry value in another `LiveMapValueType.create` ([RTLMV3](#RTLMV3)) call. Supports arbitrarily deep nesting of `LiveMapValueType` and `LiveCounterValueType` values within entries. - `(RTLMV1)` `LiveMapValueType` is an immutable value type representing the intent to create a new `LiveMap` with specific initial entries - `(RTLMV2)` `LiveMapValueType` has the following internal properties: - `(RTLMV2a)` `entries` `Dict` (optional) - the initial entries for the `LiveMap` to be created -- `(RTLMV3)` `LiveMap.create` static factory function: +- `(RTLMV3)` `LiveMapValueType.create` static factory function: - `(RTLMV3a)` Expects the following arguments: - `(RTLMV3a1)` `entries` `Dict` (optional) - the initial entries for the new `LiveMap` object - `(RTLMV3b)` Returns a new `LiveMapValueType` instance with the internal `entries` set to the provided `entries` (or undefined if omitted) @@ -1042,34 +1042,32 @@ Types and their properties/methods are public and exposed to users by default. A update: Object // RTLO4b4a noop: Boolean // RTLO4b4b - class LiveCounter extends LiveObject: // RTLC*, RTLC1 - value() -> Number // RTLC5, internal - increment(Number amount) => io // RTLC12, internal - decrement(Number amount) => io // RTLC13, internal - static create(Number initialCount?) -> LiveCounterValueType // RTLCV3 + class LiveCounter extends LiveObject: // RTLC*, RTLC1, internal + value() -> Number // RTLC5 + increment(Number amount) => io // RTLC12 + decrement(Number amount) => io // RTLC13 interface LiveCounterUpdate extends LiveObjectUpdate: // RTLC11, RTLC11a, internal update: { amount: Number } // RTLC11b, RTLC11b1 class LiveMap extends LiveObject: // RTLM*, RTLM1, internal - clearTimeserial: String? // RTLM25, internal - get(key: String) -> (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)? // RTLM5, internal - size() -> Number // RTLM10, internal - entries() -> [String, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)?][] // RTLM11, internal - keys() -> String[] // RTLM12, internal - values() -> (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)?[] // RTLM13, internal - set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounterValueType | LiveMapValueType) value) => io // RTLM20, internal - remove(String key) => io // RTLM21, internal - static create(Dict entries?) -> LiveMapValueType // RTLMV3 + clearTimeserial: String? // RTLM25 + get(key: String) -> (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)? // RTLM5 + size() -> Number // RTLM10 + entries() -> [String, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)?][] // RTLM11 + keys() -> String[] // RTLM12 + values() -> (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounter | LiveMap)?[] // RTLM13 + set(String key, (Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounterValueType | LiveMapValueType) value) => io // RTLM20 + remove(String key) => io // RTLM21 interface LiveMapUpdate extends LiveObjectUpdate: // RTLM18, RTLM18a, internal update: Dict // RTLM18b class LiveCounterValueType: // RTLCV* - // created via LiveCounter.create(), RTLCV3 + static create(Number initialCount?) -> LiveCounterValueType // RTLCV3 class LiveMapValueType: // RTLMV* - // created via LiveMap.create(), RTLMV3 + static create(Dict entries?) -> LiveMapValueType // RTLMV3 interface PathObjectSubscriptionEvent: // RTPO19d object: PathObject // RTPO19d1 From 27d523ac4b1995a579ffc2ad03a1235cd04dd0ce Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 08:57:45 -0300 Subject: [PATCH 11/43] Reword RTO23d to use PathObject's path/root property names Previously RTO23d described the returned PathObject as "wrapping the `LiveMap` with id `root`", which is misleading: `PathObject` doesn't wrap a single resolved value, it has a `path` ([RTPO2a](#RTPO2)) and a separate `root` ([RTPO2b](#RTPO2)) property. The follow-on sentence also restated this in a slightly different way, leaving redundancy. Reword to a single sentence that names the two properties directly and assigns each. Addresses [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3260885689 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 1a018272e..4eabe5883 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -19,7 +19,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO23a)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - `(RTO23b)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - `(RTO23c)` If the [RTO17](#RTO17) sync state is not `SYNCED`, waits for the sync state to transition to `SYNCED` - - `(RTO23d)` Returns a `PathObject` ([RTPO1](#RTPO1)) wrapping the `LiveMap` with id `root` from the internal `ObjectsPool`. The `PathObject` is created with an empty path, rooted at the `root` `LiveMap` + - `(RTO23d)` Returns a new `PathObject` ([RTPO1](#RTPO1)) with `path` ([RTPO2a](#RTPO2)) set to an empty list and `root` ([RTPO2b](#RTPO2)) set to the `LiveMap` with id `root` from the internal `ObjectsPool` - `(RTO11)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11a)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11a1)` This clause has been replaced by [RTLMV3](#RTLMV3). From b4d0ba795a07aff0ef18f1c10b563844ca4c9ae0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 08:58:10 -0300 Subject: [PATCH 12/43] Drop "public" modifier from RTLO4b and RTLO4c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LiveObject` is now marked `internal`, so its `subscribe` and `unsubscribe` methods can no longer meaningfully be described as public — they are callable only from within the internal class hierarchy (specifically from `Instance#subscribe`/`unsubscribe`). Drop the "public" prefix from both spec points to avoid implying a user-facing access modifier. The "user may provide a listener" sub-clauses (RTLO4b3, RTLO4c2) are left as-is — strictly the listener comes via `Instance#subscribe` now, but that's a more sweeping rewording outside the scope of this comment. Addresses [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3259389816 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 4eabe5883..5a9f0de57 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -313,7 +313,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO3e)` protected `tombstonedAt` (optional) Time - a timestamp indicating when this object was tombstoned. This property is nullable, and specification points that manipulate this value maintain the invariant that it is non-null if and only if `isTombstone` is `true` - `(RTLO3e1)` Set to undefined/null when the `LiveObject` is initialized - `(RTLO4)` `LiveObject` methods: - - `(RTLO4b)` public `subscribe` - subscribes a user to data updates on this `LiveObject` instance + - `(RTLO4b)` `subscribe` - subscribes a user to data updates on this `LiveObject` instance - `(RTLO4b1)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - `(RTLO4b2)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - `(RTLO4b3)` A user may provide a listener to subscribe to data updates on this `LiveObject` instance @@ -328,7 +328,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4b5b)` This clause has been replaced by [RTLO4b7](#RTLO4b7) - `(RTLO4b7)` Returns a [`Subscription`](../features#SUB1) object - `(RTLO4b6)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status - - `(RTLO4c)` public `unsubscribe` - unsubscribes a previously registered listener + - `(RTLO4c)` `unsubscribe` - unsubscribes a previously registered listener - `(RTLO4c1)` This operation does not require any specific channel modes to be granted, nor does it require the channel to be in a specific state - `(RTLO4c2)` A user may provide a listener they wish to deregister from receiving data updates for this `LiveObject` - `(RTLO4c3)` Once deregistered, subsequent data updates for this `LiveObject` must not result in the listener being called From 9c9a83f38a309bee4678eee88a1e83d10c31de47 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 09:22:19 -0300 Subject: [PATCH 13/43] Drop RTPO21 and RTINS18 (`subscribeIterator` spec points) These spec points required SDKs to provide a stream/iterable variant of `PathObject#subscribe` and `Instance#subscribe`. But the principle they encode -- "if there's a callback API, expose a platform-idiomatic stream variant too" -- is not LiveObjects-specific. None of the other callback subscriptions in the Ably specs (Channel#subscribe, Connection.on, Presence#subscribe, etc.) carry an equivalent clause, and encoding it per-API would not scale. A reader also might assume the normative `should` implied something LiveObjects-specific. Drop both prose blocks and the corresponding IDL lines. SDKs that want to expose a Swift `AsyncSequence`, Kotlin `Flow`, etc. variant are free to do so; the existing `subscribeIterator` methods in ably-js (`src/plugins/liveobjects/pathobject.ts:411`, `src/plugins/liveobjects/instance.ts:226`) are unaffected. If the broader principle is worth surfacing, it can be added as a single non-normative note alongside the existing `Subscription` / `SUB1` material in features.md -- separate change, separate discussion. Addresses [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3259912384 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 5a9f0de57..dd2d26449 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -917,11 +917,6 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO20)` `PathObject#unsubscribe` function: - `(RTPO20a)` Accepts a `listener` argument and deregisters it from receiving further events for this `PathObject`'s path - `(RTPO20b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status -- `(RTPO21)` The client library should provide a method that allows consuming subscription events as a stream or iterable, rather than via a callback. A suggested name for this method is `subscribeIterator`: - - `(RTPO21a)` Expects the following arguments: - - `(RTPO21a1)` `options` `PathObjectSubscriptionOptions` (optional) - same options as `PathObject#subscribe` ([RTPO19b](#RTPO19b)) - - `(RTPO21b)` Returns a stream or iterable that yields `PathObjectSubscriptionEvent` objects, using the idiomatic construct for the language (e.g. async iterators, channels, flows, or async sequences) - - `(RTPO21c)` Internally wraps `PathObject#subscribe` ([RTPO19](#RTPO19)), converting the callback-based subscription into the appropriate streaming or iterable pattern ### Instance @@ -994,10 +989,6 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS17)` `Instance#unsubscribe` function: - `(RTINS17a)` Accepts a `listener` argument and deregisters it from receiving further events using `LiveObject#unsubscribe` ([RTLO4c](#RTLO4c)) - `(RTINS17b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status -- `(RTINS18)` The client library should provide a method that allows consuming subscription events as a stream or iterable, rather than via a callback. A suggested name for this method is `subscribeIterator`: - - `(RTINS18a)` If the wrapped value is not a `LiveObject`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - - `(RTINS18b)` Returns a stream or iterable that yields `InstanceSubscriptionEvent` objects, using the idiomatic construct for the language (e.g. async iterators, channels, flows, or async sequences) - - `(RTINS18c)` Internally wraps `Instance#subscribe` ([RTINS16](#RTINS16)), converting the callback-based subscription into the appropriate streaming or iterable pattern ## Interface Definition {#idl} @@ -1098,7 +1089,6 @@ Types and their properties/methods are public and exposed to users by default. A decrement(Number amount?) => io // RTPO18 subscribe((PathObjectSubscriptionEvent) -> listener, PathObjectSubscriptionOptions? options) -> Subscription // RTPO19 unsubscribe((PathObjectSubscriptionEvent) -> listener) // RTPO20 - subscribeIterator(PathObjectSubscriptionOptions? options) -> Stream // RTPO21 class Instance: // RTINS* id: String? // RTINS3 @@ -1116,4 +1106,3 @@ Types and their properties/methods are public and exposed to users by default. A decrement(Number amount?) => io // RTINS15 subscribe((InstanceSubscriptionEvent) -> listener) -> Subscription // RTINS16 unsubscribe((InstanceSubscriptionEvent) -> listener) // RTINS17 - subscribeIterator() -> Stream // RTINS18 From d4662fa8ade6b67b4dfb69b3be7232aa8926c739 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 13:31:58 -0300 Subject: [PATCH 14/43] Specify public-facing types for subscription event message field Previously `PathObjectSubscriptionEvent.message` and `InstanceSubscriptionEvent.message` were typed as `ObjectMessage`, the canonical wire type. ably-js (the reference implementation) does not expose the raw wire `ObjectMessage` to users; it maps to a separate public type via `toUserFacingMessage`, which also recursively converts the embedded `ObjectOperation` -- most notably resolving `mapCreateWithObjectId` / `counterCreateWithObjectId` back to the original `mapCreate` / `counterCreate`. Introduce a `PublicAPI::` namespace-prefix convention in CONTRIBUTING for spec-side disambiguation of public-API types whose natural names clash with canonical wire/internal concepts. Apply the convention by introducing `PublicAPI::ObjectMessage` (PAOM*) and `PublicAPI::ObjectOperation` (PAOOP*) in objects-features.md. Retype the `message` field on both subscription event interfaces, update RTO24b4 to construct via PAOM3, and add a PAOOP3 procedure that resolves the `*CreateWithObjectId` variants back to their derived `*Create` forms (retained per RTLCV4g5 / RTLMV4j5). Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 8 ++++ specifications/objects-features.md | 76 ++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c96bd1ad..4ad427100 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,14 @@ Historically, before the above guidance was established - in particular around _ This left us open to the problem that client library references to spec items could end up semantically invalid if that spec point was re-used later. For example, if `XXX1a` and `XXX1c` exist but `XXX1b` doesn’t because it was removed in the past (prior to this guidance being established), then we should introduce `XXX1d` for the new spec item rather than re-using `XXX1b`. +## Public-API namespacing for name clashes + +Most spec types are public API by default (the IDL marks the exceptions with `internal`). When a public-API type would have the same natural name as an existing internal/wire concept, the first preference is to rename the internal concept so the public type can take the unqualified name. Where no good rename exists for the internal concept, or where renaming it would cause excessive churn or inconsistency across the spec, the spec instead qualifies the public type with a `PublicAPI::` namespace prefix (e.g. `PublicAPI::ObjectMessage`). + +This is purely a spec-side disambiguation: SDKs should expose the type to users under its unqualified name (here, `ObjectMessage`). Where an SDK's language uses a single flat namespace and cannot have two types with that name, the canonical/wire concept may be renamed internally (e.g. `WireObjectMessage`) to free up the public name. + +The `PublicAPI::` prefix is only introduced when there is an actual clash; the bare name remains the canonical reference everywhere else. + ## SDK API docstrings The `api-docstrings.md` file is a set of language-agnostic reference API commentaries for SDK developers to use when adding docstring comments to Ably SDKs. For new fields, this file should be modified in the same PR that makes the spec changes for those fields. diff --git a/specifications/objects-features.md b/specifications/objects-features.md index dd2d26449..655c4197d 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -294,7 +294,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO24b1)` Determine the paths in the LiveObjects tree at which the updated `LiveObject` is located - `(RTO24b2)` For each registered subscription, check whether the event path starts with (or equals) the subscription's path - `(RTO24b3)` If the event path matches, apply depth filtering: the event is dispatched to the subscription if the number of path segments from the subscription path to the event path plus 1 does not exceed the subscription's `depth` option (or if `depth` is undefined). Formally, the event is dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth` - - `(RTO24b4)` Create a `PathObjectSubscriptionEvent` with a `PathObject` pointing to the event path and the `ObjectMessage` that caused the change, and call the subscription's listener + - `(RTO24b4)` Create a `PathObjectSubscriptionEvent` whose `object` is a `PathObject` pointing to the event path and whose `message` is a `PublicAPI::ObjectMessage` derived from the source `ObjectMessage` per [PAOM3](#PAOM3), and call the subscription's listener - `(RTO24b5)` If a listener throws an error, the error must be caught and logged without affecting the dispatch to other subscriptions ### LiveObject @@ -910,7 +910,7 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO19c)` Returns a [`Subscription`](../features#SUB1) object - `(RTPO19d)` The listener receives a `PathObjectSubscriptionEvent` object with: - `(RTPO19d1)` `object` - a `PathObject` pointing to the path where the change occurred - - `(RTPO19d2)` `message` `ObjectMessage` (optional) - the `ObjectMessage` that caused the change + - `(RTPO19d2)` `message` `PublicAPI::ObjectMessage` (optional) - if the event was caused by an `ObjectMessage` received on the channel, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from that source `ObjectMessage` per [PAOM3](#PAOM3); otherwise omitted - `(RTPO19e)` The subscription is path-based: it follows the path, not a specific object. If the object at the path changes identity (e.g. via a `MAP_SET` operation replacing it), the subscription continues to deliver events for the new object at that path - `(RTPO19f)` Events at child paths bubble up to the subscription, subject to depth filtering. For example, a subscription at path `a.b` receives events for changes at `a.b`, `a.b.c`, `a.b.c.d`, etc., depending on the configured depth. The dispatch rules are described in [RTO24b](#RTO24b) - `(RTPO19g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status @@ -982,7 +982,7 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS16c)` Subscribes to data updates on the underlying `LiveObject` using `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) - `(RTINS16d)` The listener receives an `InstanceSubscriptionEvent` object with: - `(RTINS16d1)` `object` - an `Instance` wrapping the underlying `LiveObject` - - `(RTINS16d2)` `message` `ObjectMessage` (optional) - the `ObjectMessage` that caused the change + - `(RTINS16d2)` `message` `PublicAPI::ObjectMessage` (optional) - if the event was caused by an `ObjectMessage` received on the channel, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from that source `ObjectMessage` per [PAOM3](#PAOM3); otherwise omitted - `(RTINS16e)` Returns a [`Subscription`](../features#SUB1) object - `(RTINS16f)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the tree - `(RTINS16g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status @@ -990,6 +990,49 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS17a)` Accepts a `listener` argument and deregisters it from receiving further events using `LiveObject#unsubscribe` ([RTLO4c](#RTLO4c)) - `(RTINS17b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status +### PublicAPI::ObjectMessage + +- `(PAOM1)` A `PublicAPI::ObjectMessage` is the user-facing representation of an inbound `ObjectMessage` ([OM1](../features#OM1)) that carried an operation. It is delivered to user subscription listeners (see [RTPO19d2](#RTPO19d2), [RTINS16d2](#RTINS16d2)) so that user code can inspect the metadata of the message that triggered an object change. The `PublicAPI::` prefix is used to avoid a name clash with `ObjectMessage`; SDKs expose this type to users as `ObjectMessage`. +- `(PAOM2)` The attributes available in a `PublicAPI::ObjectMessage` are: + - `(PAOM2a)` `id` string - the `id` ([OM2a](../features#OM2a)) of the source `ObjectMessage` + - `(PAOM2b)` `clientId` string (optional) - the `clientId` ([OM2b](../features#OM2b)) of the source `ObjectMessage` + - `(PAOM2c)` `connectionId` string - the `connectionId` ([OM2c](../features#OM2c)) of the source `ObjectMessage` + - `(PAOM2d)` `timestamp` Time - the `timestamp` ([OM2e](../features#OM2e)) of the source `ObjectMessage` + - `(PAOM2e)` `channel` string - the name of the channel on which the source `ObjectMessage` was received + - `(PAOM2f)` `operation` `PublicAPI::ObjectOperation` ([PAOOP1](#PAOOP1)) - a `PublicAPI::ObjectOperation` derived per [PAOOP3](#PAOOP3) from the `operation` ([OM2f](../features#OM2f)) of the source `ObjectMessage` + - `(PAOM2g)` `serial` string (optional) - the `serial` ([OM2h](../features#OM2h)) of the source `ObjectMessage` + - `(PAOM2h)` `serialTimestamp` Time (optional) - the `serialTimestamp` ([OM2j](../features#OM2j)) of the source `ObjectMessage` + - `(PAOM2i)` `siteCode` string (optional) - the `siteCode` ([OM2i](../features#OM2i)) of the source `ObjectMessage` + - `(PAOM2j)` `extras` JSON-encodable object (optional) - the `extras` ([OM2d](../features#OM2d)) of the source `ObjectMessage` +- `(PAOM3)` To construct a `PublicAPI::ObjectMessage` from a source `ObjectMessage` received on a channel `channel`: + - `(PAOM3a)` Set the `channel` attribute to `channel.name` + - `(PAOM3b)` Copy `id`, `clientId`, `connectionId`, `timestamp`, `serial`, `serialTimestamp`, `siteCode`, and `extras` from the source `ObjectMessage` to the corresponding attributes of the `PublicAPI::ObjectMessage` + - `(PAOM3c)` Set `operation` to a `PublicAPI::ObjectOperation` derived per [PAOOP3](#PAOOP3) from the `operation` ([OM2f](../features#OM2f)) of the source `ObjectMessage` + +### PublicAPI::ObjectOperation + +- `(PAOOP1)` A `PublicAPI::ObjectOperation` is the user-facing representation of an `ObjectOperation` ([OOP1](../features#OOP1)). It is the type of the `operation` attribute of a `PublicAPI::ObjectMessage` ([PAOM2f](#PAOM2f)). The `PublicAPI::` prefix is used to avoid a name clash with `ObjectOperation`; SDKs expose this type to users as `ObjectOperation`. It differs from `ObjectOperation` in that it does not carry the `mapCreateWithObjectId` ([OOP3p](../features#OOP3p)) or `counterCreateWithObjectId` ([OOP3q](../features#OOP3q)) variants: these are outbound-only representations that are resolved back to their derived `MapCreate` / `CounterCreate` forms when constructing a `PublicAPI::ObjectOperation`. +- `(PAOOP2)` The attributes available in a `PublicAPI::ObjectOperation` are: + - `(PAOOP2a)` `action` `ObjectOperationAction` ([OOP2](../features#OOP2)) - the `action` ([OOP3a](../features#OOP3a)) of the source `ObjectOperation` + - `(PAOOP2b)` `objectId` string - the `objectId` ([OOP3b](../features#OOP3b)) of the source `ObjectOperation` + - `(PAOOP2c)` `mapCreate` `MapCreate` (optional) - the `MapCreate` payload, if applicable (see [PAOOP3b](#PAOOP3b)) + - `(PAOOP2d)` `mapSet` `MapSet` (optional) - the `mapSet` ([OOP3k](../features#OOP3k)) of the source `ObjectOperation` + - `(PAOOP2e)` `mapRemove` `MapRemove` (optional) - the `mapRemove` ([OOP3l](../features#OOP3l)) of the source `ObjectOperation` + - `(PAOOP2f)` `counterCreate` `CounterCreate` (optional) - the `CounterCreate` payload, if applicable (see [PAOOP3c](#PAOOP3c)) + - `(PAOOP2g)` `counterInc` `CounterInc` (optional) - the `counterInc` ([OOP3n](../features#OOP3n)) of the source `ObjectOperation` + - `(PAOOP2h)` `objectDelete` `ObjectDelete` (optional) - the `objectDelete` ([OOP3o](../features#OOP3o)) of the source `ObjectOperation` + - `(PAOOP2i)` `mapClear` `MapClear` (optional) - the `mapClear` ([OOP3r](../features#OOP3r)) of the source `ObjectOperation` +- `(PAOOP3)` To construct a `PublicAPI::ObjectOperation` from a source `ObjectOperation`: + - `(PAOOP3a)` Copy `action`, `objectId`, `mapSet`, `mapRemove`, `counterInc`, `objectDelete`, and `mapClear` from the source `ObjectOperation` to the corresponding attributes of the `PublicAPI::ObjectOperation` + - `(PAOOP3b)` Set `mapCreate` as follows: + - `(PAOOP3b1)` If `mapCreate` ([OOP3j](../features#OOP3j)) is present on the source, set `mapCreate` to that value + - `(PAOOP3b2)` Else if `mapCreateWithObjectId` ([OOP3p](../features#OOP3p)) is present on the source, set `mapCreate` to the `MapCreate` from which it was derived (retained per [RTLMV4j5](#RTLMV4j5)) + - `(PAOOP3b3)` Otherwise omit `mapCreate` + - `(PAOOP3c)` Set `counterCreate` as follows: + - `(PAOOP3c1)` If `counterCreate` ([OOP3m](../features#OOP3m)) is present on the source, set `counterCreate` to that value + - `(PAOOP3c2)` Else if `counterCreateWithObjectId` ([OOP3q](../features#OOP3q)) is present on the source, set `counterCreate` to the `CounterCreate` from which it was derived (retained per [RTLCV4g5](#RTLCV4g5)) + - `(PAOOP3c3)` Otherwise omit `counterCreate` + ## Interface Definition {#idl} Describes types for RealtimeObject.\ @@ -1062,14 +1105,37 @@ Types and their properties/methods are public and exposed to users by default. A interface PathObjectSubscriptionEvent: // RTPO19d object: PathObject // RTPO19d1 - message: ObjectMessage? // RTPO19d2 + message: PublicAPI::ObjectMessage? // RTPO19d2 interface PathObjectSubscriptionOptions: // RTPO19b depth: Number? // RTPO19b1 interface InstanceSubscriptionEvent: // RTINS16d object: Instance // RTINS16d1 - message: ObjectMessage? // RTINS16d2 + message: PublicAPI::ObjectMessage? // RTINS16d2 + + class PublicAPI::ObjectMessage: // PAOM* + id: String // PAOM2a + clientId: String? // PAOM2b + connectionId: String // PAOM2c + timestamp: Time // PAOM2d + channel: String // PAOM2e + operation: PublicAPI::ObjectOperation // PAOM2f + serial: String? // PAOM2g + serialTimestamp: Time? // PAOM2h + siteCode: String? // PAOM2i + extras: JsonObject? // PAOM2j + + class PublicAPI::ObjectOperation: // PAOOP* + action: ObjectOperationAction // PAOOP2a + objectId: String // PAOOP2b + mapCreate: MapCreate? // PAOOP2c + mapSet: MapSet? // PAOOP2d + mapRemove: MapRemove? // PAOOP2e + counterCreate: CounterCreate? // PAOOP2f + counterInc: CounterInc? // PAOOP2g + objectDelete: ObjectDelete? // PAOOP2h + mapClear: MapClear? // PAOOP2i class PathObject: // RTPO* path() -> String // RTPO4 From eb3a2c9759eba61e4917bd91f65147781b4da159 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 15:20:24 -0300 Subject: [PATCH 15/43] Propagate source ObjectMessage on LiveObjectUpdate Introduces an optional `objectMessage` field on `LiveObjectUpdate` (RTLO4b4d) -- the source `ObjectMessage` that caused the update, if any -- and threads it through the apply procedures so that every non-noop `LiveObjectUpdate` carries a reference to the source `ObjectMessage`. Mirrors the structure ably-js uses: each apply procedure takes the source `ObjectMessage` as an additional argument and sets `objectMessage` on the returned update; pure data-diff helpers (RTLC14, RTLM22) are left untouched, with their callers (RTLC6, RTLM6) setting `objectMessage` on the resulting update before returning. Apply procedures updated to accept `ObjectMessage`: - LiveCounter: RTLC6, RTLC8, RTLC9, RTLC16 - LiveMap: RTLM6, RTLM7, RTLM8, RTLM16, RTLM23, RTLM24 The two sync override procedures (RTLC6, RTLM6) previously accepted `ObjectState` as input; they now accept the wrapping `ObjectMessage` and extract `ObjectState` from `ObjectMessage.object`. A parenthetical caveat makes it clear that the provided `ObjectMessage` is guaranteed to have its `object` field populated. Other apply procedures do not read fields off the `ObjectMessage` parameter -- it is purely threaded onto the returned update -- so no analogous precondition is needed for them. Caller sites updated to pass the source `ObjectMessage`: - Operation dispatchers: RTLC7d1, RTLC7d5, RTLM15d1, RTLM15d6, RTLM15d7, RTLM15d8 - Sync caller: RTO5c1a1, RTO5c1b1a, RTO5c1b1b OBJECT_DELETE emit clauses (RTLC7d4a, RTLM15d5a) construct their `LiveObjectUpdate` inline rather than calling an apply procedure; the `objectMessage` field is set directly in those clauses. This commit lays the groundwork for addressing [1] (which asked for the `ObjectMessage` to be added to the `LiveObjectUpdate` so that all emit sites can specify the value to use). A follow-up commit will surface this via the user-facing subscription events. [1] https://github.com/ably/specification/pull/427#discussion_r3259541256 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 76 +++++++++++++++++------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 655c4197d..2dddee45b 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -138,7 +138,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO4b)` If the `HAS_OBJECTS` flag is 0 or there is no `flags` field, the sync sequence must be considered complete immediately, and the client library must perform the following actions in order: - `(RTO4b1)` All objects except the one with id `root` must be removed from the internal `ObjectsPool` - `(RTO4b2)` The data for the `LiveMap` with id `root` must be set to the value described in [RTLM4c](#RTLM4c). Note that the client SDK must not create a new `LiveMap` instance with id `root`; it must only clear the internal data of the existing `LiveMap` with id `root` - - `(RTO4b2a)` Emit a `LiveMapUpdate` object for the `LiveMap` with ID `root`, with `LiveMapUpdate.update` consisting of entries for the keys that were removed, each set to `removed` + - `(RTO4b2a)` Emit a `LiveMapUpdate` object for the `LiveMap` with ID `root`, with `LiveMapUpdate.update` consisting of entries for the keys that were removed, each set to `removed`, and without populating `LiveMapUpdate.objectMessage` - `(RTO4b3)` The `SyncObjectsPool` must be cleared - `(RTO4b5)` This clause has been replaced by [RTO4d](#RTO4d) - `(RTO4b4)` Perform the actions for objects sync completion as described in [RTO5c](#RTO5c) @@ -165,12 +165,12 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO5c)` When the objects sync has completed, the client library must perform the following actions in order: - `(RTO5c1)` For each `ObjectMessage` in the `SyncObjectsPool`, let `ObjectState` be `ObjectMessage.object`: - `(RTO5c1a)` If an object with `ObjectState.objectId` exists in the internal `ObjectsPool`: - - `(RTO5c1a1)` Replace the internal data for the object as described in [RTLC6](#RTLC6) or [RTLM6](#RTLM6) depending on the object type, passing in current `ObjectState` + - `(RTO5c1a1)` Replace the internal data for the object as described in [RTLC6](#RTLC6) or [RTLM6](#RTLM6) depending on the object type, passing in the current `ObjectMessage` - `(RTO5c1a2)` Store the `LiveObjectUpdate` object returned by the operation, along with a reference to the updated object - `(RTO5c1b)` If an object with `ObjectState.objectId` does not exist in the internal `ObjectsPool`: - `(RTO5c1b1)` Create a new `LiveObject` using the data from `ObjectState` and add it to the internal `ObjectsPool`: - - `(RTO5c1b1a)` If `ObjectState.counter` is present, create a new `LiveCounter` per [RTLC4](#RTLC4) by passing in `ObjectState.objectId` as `objectId`, and then replace its internal data using the current `ObjectState` per [RTLC6](#RTLC6) - - `(RTO5c1b1b)` If `ObjectState.map` is present, create a new `LiveMap` per [RTLM4](#RTLM4) by passing in `ObjectState.objectId` as `objectId`, `ObjectState.map.semantics` as `semantics`, and then replace its internal data using the current `ObjectState` per [RTLM6](#RTLM6) + - `(RTO5c1b1a)` If `ObjectState.counter` is present, create a new `LiveCounter` per [RTLC4](#RTLC4) by passing in `ObjectState.objectId` as `objectId`, and then replace its internal data using the current `ObjectMessage` per [RTLC6](#RTLC6) + - `(RTO5c1b1b)` If `ObjectState.map` is present, create a new `LiveMap` per [RTLM4](#RTLM4) by passing in `ObjectState.objectId` as `objectId`, `ObjectState.map.semantics` as `semantics`, and then replace its internal data using the current `ObjectMessage` per [RTLM6](#RTLM6) - `(RTO5c1b1c)` This clause has been deleted (redundant to [RTO5f3](#RTO5f3)). - `(RTO5c2)` Remove any objects from the internal `ObjectsPool` for which `objectId`s were not received during the sync sequence - `(RTO5c2a)` The object with ID `root` must not be removed from `ObjectsPool`, as per [RTO3b](#RTO3b) @@ -320,6 +320,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4b4)` An update to `LiveObject` data is communicated by internally emitting a `LiveObjectUpdate` object for this `LiveObject`, or in any other platform-appropriate manner: - `(RTLO4b4a)` `LiveObjectUpdate.update` contains the specific information about what was changed on the object. The exact type depends on the object type - `(RTLO4b4b)` The `LiveObjectUpdate.noop` internal property can be used to indicate that the update was a no-op + - `(RTLO4b4d)` `LiveObjectUpdate.objectMessage` is an optional `ObjectMessage` - the source `ObjectMessage` that caused this update, if any - `(RTLO4b4c)` When a `LiveObjectUpdate` is emitted: - `(RTLO4b4c1)` If `LiveObjectUpdate` is indicated to be a no-op, do nothing - `(RTLO4b4c2)` Otherwise, the registered listener is called with the `LiveObjectUpdate` object @@ -394,19 +395,19 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLC13a1)` `amount` `Number` - the amount by which to decrement the counter value - `(RTLC13b)` This is an alias for calling [`LiveCounter#increment`](#RTLC12) with a negative `amount` and must be implemented with the same behavior - `(RTLC13c)` If the client library chooses to delegate to `LiveCounter#increment` with a negated `amount`, then in languages where negating a non-number may result in implicit type coercion, the `amount` argument must first be validated as described in [RTLC12e1](#RTLC12e1) before proceeding -- `(RTLC6)` `LiveCounter`'s internal `data` can be replaced with the provided `ObjectState` in the following way: +- `(RTLC6)` `LiveCounter`'s internal `data` can be replaced with the `ObjectState` from a provided `ObjectMessage` (which the caller must ensure has its `object` field populated; let `ObjectState` refer to `ObjectMessage.object`) in the following way: - `(RTLC6a)` Replace the private `siteTimeserials` of the `LiveCounter` with the value from `ObjectState.siteTimeserials` - `(RTLC6e)` If `LiveCounter.isTombstone` is `true`, finish processing the `ObjectState` - `(RTLC6e1)` Return a `LiveCounterUpdate` object with `LiveCounterUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLC6f)` If `ObjectState.tombstone` is `true`, tombstone the current `LiveCounter` using [`LiveObject.tombstone`](#RTLO4e), passing in the outer `ObjectMessage` for the `ObjectState`. Finish processing the `ObjectState` - - `(RTLC6f1)` Return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to the negative `data` value that this `LiveCounter` had before being tombstoned + - `(RTLC6f)` If `ObjectState.tombstone` is `true`, tombstone the current `LiveCounter` using [`LiveObject.tombstone`](#RTLO4e), passing in the `ObjectMessage`. Finish processing the `ObjectState` + - `(RTLC6f1)` Return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to the negative `data` value that this `LiveCounter` had before being tombstoned, and `LiveCounterUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLC6g)` Store the current `data` value as `previousData` for use in [RTLC6h](#RTLC6h) - `(RTLC6b)` Set the private flag `createOperationIsMerged` to `false` - `(RTLC6c)` Set `data` to the value of `ObjectState.counter.count`, or to 0 if it does not exist - - `(RTLC6d)` If `ObjectState.createOp` is present, merge the initial value into the `LiveCounter` as described in [RTLC16](#RTLC16), passing in the `ObjectState.createOp` instance. Discard the `LiveCounterUpdate` object returned by the merge operation + - `(RTLC6d)` If `ObjectState.createOp` is present, merge the initial value into the `LiveCounter` as described in [RTLC16](#RTLC16), passing in the `ObjectState.createOp` instance and the `ObjectMessage`. Discard the `LiveCounterUpdate` object returned by the merge operation - `(RTLC6d1)` This clause has been replaced by [RTLC10a](#RTLC10a) - `(RTLC6d2)` This clause has been replaced by [RTLC10b](#RTLC10b) - - `(RTLC6h)` Calculate the diff between `previousData` from [RTLC6g](#RTLC6g) and the current `data` per [RTLC14](#RTLC14), and return the resulting `LiveCounterUpdate` object + - `(RTLC6h)` Calculate the diff between `previousData` from [RTLC6g](#RTLC6g) and the current `data` per [RTLC14](#RTLC14), set `LiveCounterUpdate.objectMessage` on the resulting update to the provided `ObjectMessage`, and return the resulting `LiveCounterUpdate` object - `(RTLC7)` An `ObjectOperation` from `ObjectMessage.operation` can be applied to a `LiveCounter` by performing the following actions in order: - `(RTLC7f)` Expects the following arguments: - `(RTLC7f1)` `ObjectMessage` - an `ObjectMessage` instance with an existing `ObjectMessage.operation` object, with `ObjectMessage.operation.objectId` matching the Object ID of this `LiveCounter`. This `ObjectMessage` represents the operation to be applied to this `LiveCounter` @@ -417,46 +418,48 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLC7c)` If `source` is `CHANNEL`, set the entry in the private `siteTimeserials` map at the key `ObjectMessage.siteCode` to equal `ObjectMessage.serial` - `(RTLC7e)` If `LiveCounter.isTombstone` is `true`, the operation cannot be applied to the object. Finish processing the `ObjectMessage` without taking any further action. No data update event is emitted. Return `false` - `(RTLC7d)` The `ObjectMessage.operation.action` field (see [`ObjectOperationAction`](../features#OOP2)) determines the type of operation to apply: - - `(RTLC7d1)` If `ObjectMessage.operation.action` is set to `COUNTER_CREATE`, apply the operation as described in [RTLC8](#RTLC8), passing in `ObjectMessage.operation` + - `(RTLC7d1)` If `ObjectMessage.operation.action` is set to `COUNTER_CREATE`, apply the operation as described in [RTLC8](#RTLC8), passing in `ObjectMessage.operation` and `ObjectMessage` - `(RTLC7d1a)` Emit the `LiveCounterUpdate` object returned as a result of applying the operation - `(RTLC7d1b)` Return `true` - `(RTLC7d2)` This clause has been replaced by [RTLC7d5](#RTLC7d5) as of specification version 6.0.0. - `(RTLC7d2a)` This clause has been replaced by [RTLC7d5a](#RTLC7d5a) as of specification version 6.0.0. - `(RTLC7d2b)` This clause has been replaced by [RTLC7d5b](#RTLC7d5b) as of specification version 6.0.0. - - `(RTLC7d5)` If `ObjectMessage.operation.action` is set to `COUNTER_INC`, apply the operation as described in [RTLC9](#RTLC9), passing in `ObjectMessage.operation.counterInc` + - `(RTLC7d5)` If `ObjectMessage.operation.action` is set to `COUNTER_INC`, apply the operation as described in [RTLC9](#RTLC9), passing in `ObjectMessage.operation.counterInc` and `ObjectMessage` - `(RTLC7d5a)` Emit the `LiveCounterUpdate` object returned as a result of applying the operation - `(RTLC7d5b)` Return `true` - `(RTLC7d4)` If `ObjectMessage.operation.action` is set to `OBJECT_DELETE`, apply the operation as described in [RTLO5](#RTLO5), passing in `ObjectMessage` - - `(RTLC7d4a)` Emit a `LiveCounterUpdate` object after applying the `OBJECT_DELETE` operation, with `LiveCounterUpdate.update.amount` set to the negated value that this `LiveCounter` held before the operation was applied + - `(RTLC7d4a)` Emit a `LiveCounterUpdate` object after applying the `OBJECT_DELETE` operation, with `LiveCounterUpdate.update.amount` set to the negated value that this `LiveCounter` held before the operation was applied and `LiveCounterUpdate.objectMessage` set to `ObjectMessage` - `(RTLC7d4b)` Return `true` - `(RTLC7d3)` Otherwise, log a warning that an object operation message with an unsupported action has been received, and discard the current `ObjectMessage` without taking any further action. No data update event is emitted. Return `false` - `(RTLC8)` A `COUNTER_CREATE` operation can be applied to a `LiveCounter` in the following way: - `(RTLC8a)` Expects the following arguments: - `(RTLC8a1)` `ObjectOperation` + - `(RTLC8a2)` `ObjectMessage` - the source `ObjectMessage` that contains the operation - `(RTLC8d)` The return type is a `LiveCounterUpdate` object, which indicates the data update for this `LiveCounter` - `(RTLC8b)` If the private flag `createOperationIsMerged` is `true`, log a debug or trace message indicating that the operation will not be applied because a `COUNTER_CREATE` operation has already been applied to this `LiveCounter`. Discard the operation without taking any further action, and return a `LiveCounterUpdate` object with `LiveCounterUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLC8c)` Otherwise merge the initial value into the `LiveCounter` as described in [RTLC16](#RTLC16), passing in the `ObjectOperation` instance + - `(RTLC8c)` Otherwise merge the initial value into the `LiveCounter` as described in [RTLC16](#RTLC16), passing in the `ObjectOperation` instance and the `ObjectMessage` - `(RTLC8e)` Return the `LiveCounterUpdate` object returned by [RTLC16](#RTLC16) - `(RTLC9)` A `COUNTER_INC` operation can be applied to a `LiveCounter` in the following way: - `(RTLC9a)` Expects the following arguments: - `(RTLC9a1)` This clause has been replaced by [RTLC9a2](#RTLC9a2) as of specification version 6.0.0. - `(RTLC9a2)` `CounterInc` + - `(RTLC9a3)` `ObjectMessage` - the source `ObjectMessage` that contains the operation - `(RTLC9c)` The return type is a `LiveCounterUpdate` object, which indicates the data update for this `LiveCounter` - `(RTLC9b)` This clause has been replaced by [RTLC9f](#RTLC9f) as of specification version 6.0.0. - `(RTLC9d)` This clause has been replaced by [RTLC9g](#RTLC9g) as of specification version 6.0.0. - `(RTLC9e)` This clause has been replaced by [RTLC9h](#RTLC9h) as of specification version 6.0.0. - `(RTLC9f)` Add `CounterInc.number` to `data`, if it exists - - `(RTLC9g)` If `CounterInc.number` exists, return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to `CounterInc.number` + - `(RTLC9g)` If `CounterInc.number` exists, return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to `CounterInc.number` and `LiveCounterUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLC9h)` If `CounterInc.number` does not exist, return a `LiveCounterUpdate` object with `LiveCounterUpdate.noop` set to `true` - `(RTLC10)` This clause has been replaced by [RTLC16](#RTLC16) as of specification version 6.0.0. - `(RTLC10a)` This clause has been replaced by [RTLC16a](#RTLC16a) as of specification version 6.0.0. - `(RTLC10b)` This clause has been replaced by [RTLC16b](#RTLC16b) as of specification version 6.0.0. - `(RTLC10c)` This clause has been replaced by [RTLC16c](#RTLC16c) as of specification version 6.0.0. - `(RTLC10d)` This clause has been replaced by [RTLC16d](#RTLC16d) as of specification version 6.0.0. -- `(RTLC16)` The initial value from an `ObjectOperation` can be merged into this `LiveCounter` in the following way. Let `counterCreate` be `ObjectOperation.counterCreate` if present, else the `CounterCreate` from which `ObjectOperation.counterCreateWithObjectId` was derived (see [RTLCV4g5](#RTLCV4g5)): +- `(RTLC16)` The initial value from an `ObjectOperation` can be merged into this `LiveCounter` in the following way. Expects an `ObjectOperation` and an `ObjectMessage` (the source `ObjectMessage` that contains the operation) as arguments. Let `counterCreate` be `ObjectOperation.counterCreate` if present, else the `CounterCreate` from which `ObjectOperation.counterCreateWithObjectId` was derived (see [RTLCV4g5](#RTLCV4g5)): - `(RTLC16a)` Add `counterCreate.count` to `data`, if it exists - `(RTLC16b)` Set the private flag `createOperationIsMerged` to `true` - - `(RTLC16c)` If `counterCreate.count` exists, return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to `counterCreate.count` + - `(RTLC16c)` If `counterCreate.count` exists, return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to `counterCreate.count` and `LiveCounterUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLC16d)` If `counterCreate.count` does not exist, return a `LiveCounterUpdate` object with `LiveCounterUpdate.noop` set to `true` - `(RTLC14)` The diff between two `LiveCounter` data values can be calculated in the following way: - `(RTLC14a)` Expects the following arguments: @@ -573,12 +576,12 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM14a)` The method returns true if `ObjectsMapEntry.tombstone` is true - `(RTLM14c)` The method returns true if `ObjectsMapEntry.data.objectId` exists, there is an object in the local `ObjectsPool` with that id, and that `LiveObject.isTombstone` property is `true` - `(RTLM14b)` Otherwise, it returns false -- `(RTLM6)` `LiveMap` internal `data` can be replaced with the provided `ObjectState` in the following way: +- `(RTLM6)` `LiveMap` internal `data` can be replaced with the `ObjectState` from a provided `ObjectMessage` (which the caller must ensure has its `object` field populated; let `ObjectState` refer to `ObjectMessage.object`) in the following way: - `(RTLM6a)` Replace the private `siteTimeserials` of the `LiveMap` with the value from `ObjectState.siteTimeserials` - `(RTLM6e)` If `LiveMap.isTombstone` is `true`, finish processing the `ObjectState` - `(RTLM6e1)` Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLM6f)` If `ObjectState.tombstone` is `true`, tombstone the current `LiveMap` using [`LiveObject.tombstone`](#RTLO4e), passing in the outer `ObjectMessage` for the `ObjectState`. Finish processing the `ObjectState` - - `(RTLM6f1)` Return a `LiveMapUpdate` object with `LiveMapUpdate.update` consisting of entries for the keys that were removed as a result of the object being tombstoned, each set to `removed` + - `(RTLM6f)` If `ObjectState.tombstone` is `true`, tombstone the current `LiveMap` using [`LiveObject.tombstone`](#RTLO4e), passing in the `ObjectMessage`. Finish processing the `ObjectState` + - `(RTLM6f1)` Return a `LiveMapUpdate` object with `LiveMapUpdate.update` consisting of entries for the keys that were removed as a result of the object being tombstoned, each set to `removed`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLM6g)` Store the current `data` value as `previousData` for use in [RTLM6h](#RTLM6h) - `(RTLM6b)` Set the private flag `createOperationIsMerged` to `false` - `(RTLM6i)` Set the private `clearTimeserial` to `ObjectState.map.clearTimeserial`, or to `null` if not provided @@ -587,12 +590,12 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM6c1a)` This clause has been replaced by [RTLO6a](#RTLO6a) - `(RTLM6c1b)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLM6c1b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1) - - `(RTLM6d)` If `ObjectState.createOp` is present, merge the initial value into the `LiveMap` as described in [RTLM23](#RTLM23), passing in the `ObjectState.createOp` instance. Discard the `LiveMapUpdate` object returned by the merge operation + - `(RTLM6d)` If `ObjectState.createOp` is present, merge the initial value into the `LiveMap` as described in [RTLM23](#RTLM23), passing in the `ObjectState.createOp` instance and the `ObjectMessage`. Discard the `LiveMapUpdate` object returned by the merge operation - `(RTLM6d1)` This clause has been replaced by [RTLM17a](#RTLM17a) - `(RTLM6d1a)` This clause has been replaced by [RTLM17a1](#RTLM17a1) - `(RTLM6d1b)` This clause has been replaced by [RTLM17a2](#RTLM17a2) - `(RTLM6d2)` This clause has been replaced by [RTLM17b](#RTLM17b) - - `(RTLM6h)` Calculate the diff between `previousData` from [RTLM6g](#RTLM6g) and the current `data` per [RTLM22](#RTLM22), and return the resulting `LiveMapUpdate` object + - `(RTLM6h)` Calculate the diff between `previousData` from [RTLM6g](#RTLM6g) and the current `data` per [RTLM22](#RTLM22), set `LiveMapUpdate.objectMessage` on the resulting update to the provided `ObjectMessage`, and return the resulting `LiveMapUpdate` object - `(RTLM15)` An `ObjectOperation` from `ObjectMessage.operation` can be applied to a `LiveMap` by performing the following actions in order: - `(RTLM15f)` Expects the following arguments: - `(RTLM15f1)` `ObjectMessage` - an `ObjectMessage` instance with an existing `ObjectMessage.operation` object, with `ObjectMessage.operation.objectId` matching the Object ID of this `LiveMap`. This `ObjectMessage` represents the operation to be applied to this `LiveMap` @@ -603,41 +606,43 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM15c)` If `source` is `CHANNEL`, set the entry in the private `siteTimeserials` map at the key `ObjectMessage.siteCode` to equal `ObjectMessage.serial` - `(RTLM15e)` If `LiveMap.isTombstone` is `true`, the operation cannot be applied to the object. Finish processing the `ObjectMessage` without taking any further action. No data update event is emitted. Return `false` - `(RTLM15d)` The `ObjectMessage.operation.action` field (see [`ObjectOperationAction`](../features#OOP2)) determines the type of operation to apply: - - `(RTLM15d1)` If `ObjectMessage.operation.action` is set to `MAP_CREATE`, apply the operation as described in [RTLM16](#RTLM16), passing in `ObjectMessage.operation` + - `(RTLM15d1)` If `ObjectMessage.operation.action` is set to `MAP_CREATE`, apply the operation as described in [RTLM16](#RTLM16), passing in `ObjectMessage.operation` and `ObjectMessage` - `(RTLM15d1a)` Emit the `LiveMapUpdate` object returned as a result of applying the operation - `(RTLM15d1b)` Return `true` - `(RTLM15d2)` This clause has been replaced by [RTLM15d6](#RTLM15d6) as of specification version 6.0.0. - `(RTLM15d2a)` This clause has been replaced by [RTLM15d6a](#RTLM15d6a) as of specification version 6.0.0. - `(RTLM15d2b)` This clause has been replaced by [RTLM15d6b](#RTLM15d6b) as of specification version 6.0.0. - - `(RTLM15d6)` If `ObjectMessage.operation.action` is set to `MAP_SET`, apply the operation as described in [RTLM7](#RTLM7), passing in `ObjectMessage.operation.mapSet` and `ObjectMessage.serial` + - `(RTLM15d6)` If `ObjectMessage.operation.action` is set to `MAP_SET`, apply the operation as described in [RTLM7](#RTLM7), passing in `ObjectMessage.operation.mapSet`, `ObjectMessage.serial`, and `ObjectMessage` - `(RTLM15d6a)` Emit the `LiveMapUpdate` object returned as a result of applying the operation - `(RTLM15d6b)` Return `true` - `(RTLM15d3)` This clause has been replaced by [RTLM15d7](#RTLM15d7) as of specification version 6.0.0. - `(RTLM15d3a)` This clause has been replaced by [RTLM15d7a](#RTLM15d7a) as of specification version 6.0.0. - `(RTLM15d3b)` This clause has been replaced by [RTLM15d7b](#RTLM15d7b) as of specification version 6.0.0. - - `(RTLM15d7)` If `ObjectMessage.operation.action` is set to `MAP_REMOVE`, apply the operation as described in [RTLM8](#RTLM8), passing in `ObjectMessage.operation.mapRemove`, `ObjectMessage.serial` and `ObjectMessage.serialTimestamp` + - `(RTLM15d7)` If `ObjectMessage.operation.action` is set to `MAP_REMOVE`, apply the operation as described in [RTLM8](#RTLM8), passing in `ObjectMessage.operation.mapRemove`, `ObjectMessage.serial`, `ObjectMessage.serialTimestamp`, and `ObjectMessage` - `(RTLM15d7a)` Emit the `LiveMapUpdate` object returned as a result of applying the operation - `(RTLM15d7b)` Return `true` - `(RTLM15d5)` If `ObjectMessage.operation.action` is set to `OBJECT_DELETE`, apply the operation as described in [RTLO5](#RTLO5), passing in `ObjectMessage` - - `(RTLM15d5a)` Emit a `LiveMapUpdate` object with `LiveMapUpdate.update` consisting of entries for the keys that were removed as a result of applying the `OBJECT_DELETE` operation, each set to `removed` + - `(RTLM15d5a)` Emit a `LiveMapUpdate` object with `LiveMapUpdate.update` consisting of entries for the keys that were removed as a result of applying the `OBJECT_DELETE` operation, each set to `removed`, and `LiveMapUpdate.objectMessage` set to `ObjectMessage` - `(RTLM15d5b)` Return `true` - - `(RTLM15d8)` If `ObjectMessage.operation.action` is set to `MAP_CLEAR`, apply the operation as described in [RTLM24](#RTLM24), passing in `ObjectMessage.serial` + - `(RTLM15d8)` If `ObjectMessage.operation.action` is set to `MAP_CLEAR`, apply the operation as described in [RTLM24](#RTLM24), passing in `ObjectMessage.serial` and `ObjectMessage` - `(RTLM15d8a)` Emit the `LiveMapUpdate` object returned as a result of applying the operation - `(RTLM15d8b)` Return `true` - `(RTLM15d4)` Otherwise, log a warning that an object operation message with an unsupported action has been received, and discard the current `ObjectMessage` without taking any further action. No data update event is emitted. Return `false` - `(RTLM16)` A `MAP_CREATE` operation can be applied to a `LiveMap` in the following way: - `(RTLM16a)` Expects the following arguments: - `(RTLM16a1)` `ObjectOperation` + - `(RTLM16a2)` `ObjectMessage` - the source `ObjectMessage` that contains the operation - `(RTLM16e)` The return type is a `LiveMapUpdate` object, which indicates the data update for this `LiveMap` - `(RTLM16b)` If the private flag `createOperationIsMerged` is `true`, log a debug or trace message indicating that the operation will not be applied because a `MAP_CREATE` operation has already been applied to this `LiveMap`. Discard the operation without taking any further action, and return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM16c)` This clause has been deleted. - - `(RTLM16d)` Otherwise merge the initial value into the `LiveMap` as described in [RTLM23](#RTLM23), passing in the `ObjectOperation` instance + - `(RTLM16d)` Otherwise merge the initial value into the `LiveMap` as described in [RTLM23](#RTLM23), passing in the `ObjectOperation` instance and the `ObjectMessage` - `(RTLM16f)` Return the `LiveMapUpdate` object returned by [RTLM23](#RTLM23) - `(RTLM7)` A `MAP_SET` operation for a key can be applied to a `LiveMap` in the following way: - `(RTLM7d)` Expects the following arguments: - `(RTLM7d1)` This clause has been replaced by [RTLM7d3](#RTLM7d3) as of specification version 6.0.0. - `(RTLM7d3)` `MapSet` - `(RTLM7d2)` `serial` string - operation's serial value + - `(RTLM7d4)` `ObjectMessage` - the source `ObjectMessage` that contains the operation - `(RTLM7e)` The return type is a `LiveMapUpdate` object, which indicates the data update for this `LiveMap` - `(RTLM7h)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM7a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: @@ -657,13 +662,14 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7c1)` This clause has been replaced by [RTLM7g1](#RTLM7g1) as of specification version 6.0.0. - `(RTLM7g)` If `MapSet.value.objectId` is non-empty: - `(RTLM7g1)` Create a new `LiveObject` for this `objectId` in the internal `ObjectsPool` per [RTO6](#RTO6) - - `(RTLM7f)` Return a `LiveMapUpdate` object with a `LiveMapUpdate.update` map containing the key used in this operation set to `updated` + - `(RTLM7f)` Return a `LiveMapUpdate` object with a `LiveMapUpdate.update` map containing the key used in this operation set to `updated`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLM8)` A `MAP_REMOVE` operation for a key can be applied to a `LiveMap` in the following way: - `(RTLM8c)` Expects the following arguments: - `(RTLM8c1)` This clause has been replaced by [RTLM8c4](#RTLM8c4) as of specification version 6.0.0. - `(RTLM8c4)` `MapRemove` - `(RTLM8c2)` `serial` string - operation's serial value - `(RTLM8c3)` `serialTimestamp` Time - operation's serial timestamp value + - `(RTLM8c5)` `ObjectMessage` - the source `ObjectMessage` that contains the operation - `(RTLM8d)` The return type is a `LiveMapUpdate` object, which indicates the data update for this `LiveMap` - `(RTLM8g)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM8a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: @@ -681,10 +687,11 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM8f1)` This clause has been replaced by [RTLO6a](#RTLO6a) - `(RTLM8f2)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLM8f2a)` This clause has been replaced by [RTLO6b1](#RTLO6b1) - - `(RTLM8e)` Return a `LiveMapUpdate` object with a `LiveMapUpdate.update` map containing the key used in this operation set to `removed` + - `(RTLM8e)` Return a `LiveMapUpdate` object with a `LiveMapUpdate.update` map containing the key used in this operation set to `removed`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLM24)` A `MAP_CLEAR` operation can be applied to a `LiveMap` in the following way: - `(RTLM24a)` Expects the following arguments: - `(RTLM24a1)` `serial` string - the operation's serial value + - `(RTLM24a2)` `ObjectMessage` - the source `ObjectMessage` that contains the operation - `(RTLM24b)` The return type is a `LiveMapUpdate` object, which indicates the data update for this `LiveMap` - `(RTLM24c)` If the private `clearTimeserial` is non-null and is lexicographically greater than the provided `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM24d)` Set the private `clearTimeserial` to the provided `serial` @@ -692,7 +699,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM24e1)` If `ObjectsMapEntry.timeserial` is null or omitted, or the `serial` is lexicographically greater than `ObjectsMapEntry.timeserial`: - `(RTLM24e1a)` Remove the entry from the internal `data` map. The entry is not retained as a tombstone. - `(RTLM24e1b)` Record the key for the `LiveMapUpdate` as `removed` - - `(RTLM24f)` Return a `LiveMapUpdate` object with `LiveMapUpdate.update` containing each key recorded in [RTLM24e1b](#RTLM24e1b) set to `removed` + - `(RTLM24f)` Return a `LiveMapUpdate` object with `LiveMapUpdate.update` containing each key recorded in [RTLM24e1b](#RTLM24e1b) set to `removed`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLM9)` Whether a map operation can be applied to a map entry is determined as follows: - `(RTLM9a)` For a `LiveMap` with `semantics` set to `ObjectsMapSemantics.LWW` (Last-Write-Wins CRDT semantics), the operation must only be applied if its serial is strictly greater ("after") than the entry's serial when compared lexicographically - `(RTLM9b)` If both the entry serial and the operation serial are null or empty strings, they are treated as the "earliest possible" serials and considered "equal", so the operation must not be applied @@ -705,12 +712,12 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM17a2)` This clause has been replaced by [RTLM23a2](#RTLM23a2) as of specification version 6.0.0. - `(RTLM17b)` This clause has been replaced by [RTLM23b](#RTLM23b) as of specification version 6.0.0. - `(RTLM17c)` This clause has been replaced by [RTLM23c](#RTLM23c) as of specification version 6.0.0. -- `(RTLM23)` The initial value from an `ObjectOperation` can be merged into this `LiveMap` in the following way. Let `mapCreate` be `ObjectOperation.mapCreate` if present, else the `MapCreate` from which `ObjectOperation.mapCreateWithObjectId` was derived (see [RTLMV4j5](#RTLMV4j5)): +- `(RTLM23)` The initial value from an `ObjectOperation` can be merged into this `LiveMap` in the following way. Expects an `ObjectOperation` and an `ObjectMessage` (the source `ObjectMessage` that contains the operation) as arguments. Let `mapCreate` be `ObjectOperation.mapCreate` if present, else the `MapCreate` from which `ObjectOperation.mapCreateWithObjectId` was derived (see [RTLMV4j5](#RTLMV4j5)): - `(RTLM23a)` For each key-`ObjectsMapEntry` pair in `mapCreate.entries`: - - `(RTLM23a1)` If `ObjectsMapEntry.tombstone` is `false` or omitted, apply the `MAP_SET` operation to the current key as described in [RTLM7](#RTLM7), passing in `ObjectsMapEntry.data` and the current key as `MapSet`, and `ObjectsMapEntry.timeserial` as `serial`. Store the returned `LiveMapUpdate` object for use in [RTLM23c](#RTLM23c) - - `(RTLM23a2)` If `ObjectsMapEntry.tombstone` is `true`, apply the `MAP_REMOVE` operation to the current key as described in [RTLM8](#RTLM8), passing in the current key as `MapRemove`, `ObjectsMapEntry.timeserial` as `serial`, and `ObjectsMapEntry.serialTimestamp` as `serialTimestamp`. Store the returned `LiveMapUpdate` object for use in [RTLM23c](#RTLM23c) + - `(RTLM23a1)` If `ObjectsMapEntry.tombstone` is `false` or omitted, apply the `MAP_SET` operation to the current key as described in [RTLM7](#RTLM7), passing in `ObjectsMapEntry.data` and the current key as `MapSet`, `ObjectsMapEntry.timeserial` as `serial`, and the `ObjectMessage`. Store the returned `LiveMapUpdate` object for use in [RTLM23c](#RTLM23c) + - `(RTLM23a2)` If `ObjectsMapEntry.tombstone` is `true`, apply the `MAP_REMOVE` operation to the current key as described in [RTLM8](#RTLM8), passing in the current key as `MapRemove`, `ObjectsMapEntry.timeserial` as `serial`, `ObjectsMapEntry.serialTimestamp` as `serialTimestamp`, and the `ObjectMessage`. Store the returned `LiveMapUpdate` object for use in [RTLM23c](#RTLM23c) - `(RTLM23b)` Set the private flag `createOperationIsMerged` to `true` - - `(RTLM23c)` Return a single `LiveMapUpdate` object, where `LiveMapUpdate.update` is a merged map containing all key-value pairs from the `LiveMapUpdate.update` maps of the stored `LiveMapUpdate` objects. Skip any stored `LiveMapUpdate` objects marked as no-op + - `(RTLM23c)` Return a single `LiveMapUpdate` object, where `LiveMapUpdate.update` is a merged map containing all key-value pairs from the `LiveMapUpdate.update` maps of the stored `LiveMapUpdate` objects (skipping any stored `LiveMapUpdate` objects marked as no-op), and `LiveMapUpdate.objectMessage` is set to the provided `ObjectMessage` - `(RTLM19)` The `LiveMap` can be checked to determine whether it should release resources for its tombstoned `ObjectsMapEntry` entries as follows: - `(RTLM19a)` For each `ObjectsMapEntry` in the internal `data`: - `(RTLM19a1)` If `ObjectsMapEntry.tombstone` is `true`, and the difference between the current time and `ObjectsMapEntry.tombstonedAt` is greater than or equal to the [grace period](#RTO10b), remove the entry from the internal `data` map and release resources for the corresponding `ObjectsMapEntry` entity to allow it to be garbage collected @@ -1075,6 +1082,7 @@ Types and their properties/methods are public and exposed to users by default. A interface LiveObjectUpdate: // RTLO4b4, internal update: Object // RTLO4b4a noop: Boolean // RTLO4b4b + objectMessage: ObjectMessage? // RTLO4b4d class LiveCounter extends LiveObject: // RTLC*, RTLC1, internal value() -> Number // RTLC5 From 967d48815a59ca1f5920842da26a46646e926093 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 16:04:17 -0300 Subject: [PATCH 16/43] Unify dispatch and surface ObjectMessage to subscribers Restructures the dispatch step for an emitted `LiveObjectUpdate` so that it is a single point that fans out to both the `LiveObject#subscribe` listeners and the path-based subscription dispatch. This means the single no-op check at RTLO4b4c1 covers both consumers; previously path-based dispatch (RTO24b) was described as independently triggered "when a LiveObject emits a LiveObjectUpdate" with no no-op check of its own. Names the path-based dispatch step ("Path-based subscription dispatch") so RTLO4b4c can refer to it by name. RTO24b is reframed as a procedure that accepts a `LiveObject` and a `LiveObjectUpdate` as arguments rather than being self-triggered. RTLO4b4c2 is properly replaced (per CONTRIBUTING) by a new RTLO4b4c3 since the scope changed from "call the LiveObject#subscribe listener" to "fan out to both consumers". The user-facing `message` field on `PathObjectSubscriptionEvent` (RTPO19d2) and `InstanceSubscriptionEvent` (RTINS16d2), and the construction logic at RTO24b4, are tightened to derive `PublicAPI::ObjectMessage` from `LiveObjectUpdate.objectMessage` only when that field is populated AND its `operation` field is populated. This filter at the user-facing boundary preserves the descriptive meaning of `LiveObjectUpdate.objectMessage` (any source ObjectMessage, including sync) while ensuring users only see operation-carrying messages -- matching ably-js's behaviour. RTO24b4 is also split into per-field sub-clauses (RTO24b4a / RTO24b4b) to mirror the structure of RTINS16d. Addresses [1] (no-op handling in path subscription dispatch) and continues [2] (mechanism for surfacing the source `ObjectMessage` via PublicAPI::ObjectMessage). [1] https://github.com/ably/specification/pull/427#discussion_r3262637746 [2] https://github.com/ably/specification/pull/427#discussion_r3259449857 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 2dddee45b..6b4a70cd6 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -290,11 +290,13 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO22b)` `CHANNEL` - an operation received over a Realtime channel - `(RTO24)` Internal `PathObjectSubscriptionRegister` - manages path-based subscriptions for `PathObject#subscribe` ([RTPO19](#RTPO19)) - `(RTO24a)` The `RealtimeObject` instance maintains a single `PathObjectSubscriptionRegister` that manages all path-based subscriptions for the channel - - `(RTO24b)` When a `LiveObject` in the `ObjectsPool` emits a `LiveObjectUpdate` (per [RTLO4b4](#RTLO4b4)), the `PathObjectSubscriptionRegister` must determine which subscriptions should be notified: - - `(RTO24b1)` Determine the paths in the LiveObjects tree at which the updated `LiveObject` is located + - `(RTO24b)` Path-based subscription dispatch: given a `LiveObject` and a `LiveObjectUpdate`, the `PathObjectSubscriptionRegister` must determine which subscriptions should be notified by performing the following actions in order: + - `(RTO24b1)` Determine the paths in the LiveObjects tree at which the `LiveObject` is located - `(RTO24b2)` For each registered subscription, check whether the event path starts with (or equals) the subscription's path - `(RTO24b3)` If the event path matches, apply depth filtering: the event is dispatched to the subscription if the number of path segments from the subscription path to the event path plus 1 does not exceed the subscription's `depth` option (or if `depth` is undefined). Formally, the event is dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth` - - `(RTO24b4)` Create a `PathObjectSubscriptionEvent` whose `object` is a `PathObject` pointing to the event path and whose `message` is a `PublicAPI::ObjectMessage` derived from the source `ObjectMessage` per [PAOM3](#PAOM3), and call the subscription's listener + - `(RTO24b4)` Call the subscription's listener with a `PathObjectSubscriptionEvent` that has: + - `(RTO24b4a)` `object` - a `PathObject` pointing to the event path + - `(RTO24b4b)` `message` - if `LiveObjectUpdate.objectMessage` is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` derived from `LiveObjectUpdate.objectMessage` per [PAOM3](#PAOM3); otherwise omitted - `(RTO24b5)` If a listener throws an error, the error must be caught and logged without affecting the dispatch to other subscriptions ### LiveObject @@ -323,7 +325,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4b4d)` `LiveObjectUpdate.objectMessage` is an optional `ObjectMessage` - the source `ObjectMessage` that caused this update, if any - `(RTLO4b4c)` When a `LiveObjectUpdate` is emitted: - `(RTLO4b4c1)` If `LiveObjectUpdate` is indicated to be a no-op, do nothing - - `(RTLO4b4c2)` Otherwise, the registered listener is called with the `LiveObjectUpdate` object + - `(RTLO4b4c2)` This clause has been replaced by [RTLO4b4c3](#RTLO4b4c3) as of specification version 6.0.0. + - `(RTLO4b4c3)` Otherwise: + - `(RTLO4b4c3a)` The registered listener of each subscription created via `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) on this `LiveObject` is called with the `LiveObjectUpdate` + - `(RTLO4b4c3b)` Perform path-based subscription dispatch as described in [RTO24b](#RTO24b), passing this `LiveObject` and the `LiveObjectUpdate` - `(RTLO4b5)` This clause has been replaced by [RTLO4b7](#RTLO4b7) - `(RTLO4b5a)` This clause has been replaced by [RTLO4b7](#RTLO4b7) - `(RTLO4b5b)` This clause has been replaced by [RTLO4b7](#RTLO4b7) @@ -917,7 +922,7 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO19c)` Returns a [`Subscription`](../features#SUB1) object - `(RTPO19d)` The listener receives a `PathObjectSubscriptionEvent` object with: - `(RTPO19d1)` `object` - a `PathObject` pointing to the path where the change occurred - - `(RTPO19d2)` `message` `PublicAPI::ObjectMessage` (optional) - if the event was caused by an `ObjectMessage` received on the channel, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from that source `ObjectMessage` per [PAOM3](#PAOM3); otherwise omitted + - `(RTPO19d2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the [RTLO4b4](#RTLO4b4) emission that triggered this event is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted - `(RTPO19e)` The subscription is path-based: it follows the path, not a specific object. If the object at the path changes identity (e.g. via a `MAP_SET` operation replacing it), the subscription continues to deliver events for the new object at that path - `(RTPO19f)` Events at child paths bubble up to the subscription, subject to depth filtering. For example, a subscription at path `a.b` receives events for changes at `a.b`, `a.b.c`, `a.b.c.d`, etc., depending on the configured depth. The dispatch rules are described in [RTO24b](#RTO24b) - `(RTPO19g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status @@ -989,7 +994,7 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS16c)` Subscribes to data updates on the underlying `LiveObject` using `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) - `(RTINS16d)` The listener receives an `InstanceSubscriptionEvent` object with: - `(RTINS16d1)` `object` - an `Instance` wrapping the underlying `LiveObject` - - `(RTINS16d2)` `message` `PublicAPI::ObjectMessage` (optional) - if the event was caused by an `ObjectMessage` received on the channel, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from that source `ObjectMessage` per [PAOM3](#PAOM3); otherwise omitted + - `(RTINS16d2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the underlying `LiveObject#subscribe` notification is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted - `(RTINS16e)` Returns a [`Subscription`](../features#SUB1) object - `(RTINS16f)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the tree - `(RTINS16g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status From f9dc077bfcdca49575fc585406d05312ec6c3f97 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 17:25:09 -0300 Subject: [PATCH 17/43] Restructure path-based subscription dispatch Addresses the remaining unticked items in the "Event dispatch: must fix" section of the PR 427 prioritisation comment [1]: - RTO24b now iterates per `pathToThis` and dispatches against a preference-ordered list of candidate paths, matching the post-2223 ably-js implementation [2]. For a `LiveMapUpdate`, the keys whose entries changed contribute further candidates beyond `pathToThis` itself, so a subscription targeting `parentMap.someKey` is now notified when the parent map emits an update mentioning `someKey`. - RTO24c hoists out the subscription-coverage predicate (prefix + depth) as a reusable definition with worked examples. - RTO24d captures the two non-normative consequences of the dispatch model (at-most-one notification per `pathToThis`; the parent path wins when both are covered). - RTLO4f introduces a placeholder for `LiveObject.getFullPaths`; the procedure itself will be specified in a follow-up. - RTPO19 is slimmed down: dispatch-describing prose moves to RTO24, the worked depth examples move under the coverage rule, and the new RTPO19e states the actual normative behaviour of `subscribe` ("adds a subscription to the `PathObjectSubscriptionRegister`"). [1] https://github.com/ably/specification/pull/427#issuecomment-4508418686 [2] https://github.com/ably/ably-js/pull/2223 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 42 ++++++++++++++++++------------ 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 6b4a70cd6..3cb3b3ca0 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -291,13 +291,25 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO24)` Internal `PathObjectSubscriptionRegister` - manages path-based subscriptions for `PathObject#subscribe` ([RTPO19](#RTPO19)) - `(RTO24a)` The `RealtimeObject` instance maintains a single `PathObjectSubscriptionRegister` that manages all path-based subscriptions for the channel - `(RTO24b)` Path-based subscription dispatch: given a `LiveObject` and a `LiveObjectUpdate`, the `PathObjectSubscriptionRegister` must determine which subscriptions should be notified by performing the following actions in order: - - `(RTO24b1)` Determine the paths in the LiveObjects tree at which the `LiveObject` is located - - `(RTO24b2)` For each registered subscription, check whether the event path starts with (or equals) the subscription's path - - `(RTO24b3)` If the event path matches, apply depth filtering: the event is dispatched to the subscription if the number of path segments from the subscription path to the event path plus 1 does not exceed the subscription's `depth` option (or if `depth` is undefined). Formally, the event is dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth` - - `(RTO24b4)` Call the subscription's listener with a `PathObjectSubscriptionEvent` that has: - - `(RTO24b4a)` `object` - a `PathObject` pointing to the event path - - `(RTO24b4b)` `message` - if `LiveObjectUpdate.objectMessage` is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` derived from `LiveObjectUpdate.objectMessage` per [PAOM3](#PAOM3); otherwise omitted - - `(RTO24b5)` If a listener throws an error, the error must be caught and logged without affecting the dispatch to other subscriptions + - `(RTO24b1)` Let `pathsToThis` be the set of paths returned by calling `getFullPaths` ([RTLO4f](#RTLO4f)) on the `LiveObject` + - `(RTO24b2)` For each `pathToThis` in `pathsToThis`: + - `(RTO24b2a)` Construct an ordered list of candidate paths `candidatePaths`, in order of decreasing preference: + - `(RTO24b2a1)` The first (most-preferred) candidate is `pathToThis` itself + - `(RTO24b2a2)` If the `LiveObjectUpdate` is a `LiveMapUpdate`, then for each key in `LiveMapUpdate.update`, append a further candidate consisting of `pathToThis` extended by that key + - `(RTO24b2b)` For each registered subscription, find the first `eventPath` in `candidatePaths` that the subscription covers per [RTO24c1](#RTO24c1). If no such `eventPath` exists, do nothing for this subscription. Otherwise, call the subscription's listener exactly once with a `PathObjectSubscriptionEvent` that has: + - `(RTO24b2b1)` `object` - a new `PathObject` ([RTPO1](#RTPO1)) with `path` ([RTPO2a](#RTPO2)) set to `eventPath` and `root` ([RTPO2b](#RTPO2)) set to the `LiveMap` with id `root` from the internal `ObjectsPool` + - `(RTO24b2b2)` `message` - if `LiveObjectUpdate.objectMessage` is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` derived from `LiveObjectUpdate.objectMessage` per [PAOM3](#PAOM3); otherwise omitted + - `(RTO24b2c)` If a listener throws an error, the error must be caught and logged without affecting the dispatch to other subscriptions, nor to other `pathToThis` iterations + - `(RTO24c)` Subscription coverage: + - `(RTO24c1)` A subscription with subscribed path `subPath` and `depth` option is said to *cover* a path `eventPath` if and only if `subPath` is a prefix of `eventPath` (treating `subPath` as a prefix of itself, so that an exact path match also satisfies this condition), and either `depth` is undefined/null or `eventPath.length - subPath.length + 1 <= depth` + - `(RTO24c2)` (non-normative) Coverage examples, for a subscription at path `["users"]`: + - `(RTO24c2a)` With `depth` undefined/null: covers `["users"]`, `["users", "emma"]`, `["users", "emma", "visits"]`, and so on at any depth + - `(RTO24c2b)` With `depth = 1`: covers `["users"]`; does not cover `["users", "emma"]` or any deeper path + - `(RTO24c2c)` With `depth = 2`: covers `["users"]` and `["users", "emma"]`; does not cover `["users", "emma", "visits"]` or any deeper path + - `(RTO24c2d)` With any `depth`: does not cover `["admins"]` or `["userPosts"]`, since the subscription path is not a prefix of either + - `(RTO24d)` (non-normative) Consequences of the dispatch model in [RTO24b](#RTO24b): + - `(RTO24d1)` Each subscription receives at most one notification per `pathToThis`. When the same subscription's subscribed path covers both `pathToThis` and one of the longer candidate paths derived from it, the shorter `pathToThis` is the one reported to the listener + - `(RTO24d2)` A subscription whose subscribed path is `parentMap.someKey` still receives notifications when the parent map emits a `LiveMapUpdate` mentioning `someKey`, even though the underlying `LiveObjectUpdate` is emitted by the parent map, not by the entry's object ### LiveObject @@ -356,6 +368,8 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e3b)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLO4e3b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1) - `(RTLO4e4)` Set the `data` attribute of the `LiveObject` to the value described in [RTLC4b](#RTLC4b) or [RTLM4c](#RTLM4c), depending on the object type + - `(RTLO4f)` protected `getFullPaths` - returns the set of paths in the LiveObjects tree at which this `LiveObject` is currently located. Each path is an ordered list of string segments from the root `LiveMap`. The same `LiveObject` may be reachable from multiple paths (e.g. when it is referenced by more than one map entry) or from zero paths (e.g. when it is not a descendant of the root). Used by path-based subscription dispatch ([RTO24b](#RTO24b)) + - `(RTLO4f1)` TODO: The procedure for computing the set of paths is to be specified separately - `(RTLO5)` An `OBJECT_DELETE` operation can be applied to a `LiveObject` in the following way: - `(RTLO5a)` Expects the following arguments: - `(RTLO5a1)` `ObjectMessage` @@ -911,21 +925,17 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO18d)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - `(RTPO19)` `PathObject#subscribe` function: - `(RTPO19a)` Expects the following arguments: - - `(RTPO19a1)` `listener` - a callback function that receives a `PathObjectSubscriptionEvent` ([RTPO19d](#RTPO19d)) when a change occurs at or below this path + - `(RTPO19a1)` `listener` - a callback function that receives a `PathObjectSubscriptionEvent` ([RTPO19d](#RTPO19d)) - `(RTPO19a2)` `options` `PathObjectSubscriptionOptions` (optional) - subscription options - `(RTPO19b)` `PathObjectSubscriptionOptions` has the following properties: - - `(RTPO19b1)` `depth` `Number` (optional) - controls how many levels deep in the subtree changes trigger the listener: - - `(RTPO19b1a)` If undefined (default), the subscription receives events for changes at any depth below the subscribed path - - `(RTPO19b1b)` If `depth` is 1, only changes to the object at the exact subscribed path trigger the listener - - `(RTPO19b1c)` If `depth` is `n`, changes up to `n - 1` levels of children below the subscribed path trigger the listener - - `(RTPO19b1d)` If `depth` is provided and is not a positive integer, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003 + - `(RTPO19b1)` `depth` `Number` (optional) - controls how many levels deep in the subtree changes trigger the listener. Defaults to undefined/null. The `depth` value is interpreted by the subscription coverage rule in [RTO24c1](#RTO24c1); see [RTO24c2](#RTO24c2) for worked examples + - `(RTPO19b1a)` If `depth` is provided and is not a positive integer, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003 - `(RTPO19c)` Returns a [`Subscription`](../features#SUB1) object - `(RTPO19d)` The listener receives a `PathObjectSubscriptionEvent` object with: - `(RTPO19d1)` `object` - a `PathObject` pointing to the path where the change occurred - `(RTPO19d2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the [RTLO4b4](#RTLO4b4) emission that triggered this event is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted - - `(RTPO19e)` The subscription is path-based: it follows the path, not a specific object. If the object at the path changes identity (e.g. via a `MAP_SET` operation replacing it), the subscription continues to deliver events for the new object at that path - - `(RTPO19f)` Events at child paths bubble up to the subscription, subject to depth filtering. For example, a subscription at path `a.b` receives events for changes at `a.b`, `a.b.c`, `a.b.c.d`, etc., depending on the configured depth. The dispatch rules are described in [RTO24b](#RTO24b) - - `(RTPO19g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status + - `(RTPO19e)` Adds a subscription to the `RealtimeObject`'s `PathObjectSubscriptionRegister` ([RTO24](#RTO24)) with subscribed path equal to this `PathObject`'s `path` (per [RTPO2a](#RTPO2a)), the provided `listener`, and the provided `options.depth` + - `(RTPO19f)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status - `(RTPO20)` `PathObject#unsubscribe` function: - `(RTPO20a)` Accepts a `listener` argument and deregisters it from receiving further events for this `PathObject`'s path - `(RTPO20b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status From fade83e18456b348607fed4f42b4dec71af74aa8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 08:57:06 -0300 Subject: [PATCH 18/43] remove RTO24d added in f9dc077bfcdca49575fc585406d05312ec6c3f97 but actually would be better off in the UTS --- specifications/objects-features.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 3cb3b3ca0..d5fe08dc6 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -307,9 +307,6 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO24c2b)` With `depth = 1`: covers `["users"]`; does not cover `["users", "emma"]` or any deeper path - `(RTO24c2c)` With `depth = 2`: covers `["users"]` and `["users", "emma"]`; does not cover `["users", "emma", "visits"]` or any deeper path - `(RTO24c2d)` With any `depth`: does not cover `["admins"]` or `["userPosts"]`, since the subscription path is not a prefix of either - - `(RTO24d)` (non-normative) Consequences of the dispatch model in [RTO24b](#RTO24b): - - `(RTO24d1)` Each subscription receives at most one notification per `pathToThis`. When the same subscription's subscribed path covers both `pathToThis` and one of the longer candidate paths derived from it, the shorter `pathToThis` is the one reported to the listener - - `(RTO24d2)` A subscription whose subscribed path is `parentMap.someKey` still receives notifications when the parent map emits a `LiveMapUpdate` mentioning `someKey`, even though the underlying `LiveObjectUpdate` is emitted by the parent map, not by the entry's object ### LiveObject From a3a529277c2f0fdefd4586543f3481b4ca68cb0f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 09:13:12 -0300 Subject: [PATCH 19/43] Remove unsubscribe(listener) from LiveObject/PathObject/Instance `Subscription` (returned by `subscribe`) is now the sole deregistration mechanism, matching the ably-js public API. RTLO4c is retained as a "This clause has been deleted" stub since it existed on main; RTPO20 and RTINS17 are removed outright as they were introduced earlier in this PR branch. The corresponding `unsubscribe` declarations are also removed from the IDL. Lifted from Sachin's spec-alignment PR [1]. [1] https://github.com/ably/specification/pull/480 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index d5fe08dc6..89f5ad624 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -343,11 +343,11 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4b5b)` This clause has been replaced by [RTLO4b7](#RTLO4b7) - `(RTLO4b7)` Returns a [`Subscription`](../features#SUB1) object - `(RTLO4b6)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status - - `(RTLO4c)` `unsubscribe` - unsubscribes a previously registered listener - - `(RTLO4c1)` This operation does not require any specific channel modes to be granted, nor does it require the channel to be in a specific state - - `(RTLO4c2)` A user may provide a listener they wish to deregister from receiving data updates for this `LiveObject` - - `(RTLO4c3)` Once deregistered, subsequent data updates for this `LiveObject` must not result in the listener being called - - `(RTLO4c4)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status + - `(RTLO4c)` This clause has been deleted + - `(RTLO4c1)` This clause has been deleted + - `(RTLO4c2)` This clause has been deleted + - `(RTLO4c3)` This clause has been deleted + - `(RTLO4c4)` This clause has been deleted - `(RTLO4a)` protected `canApplyOperation` - a convenience method used to determine whether the `ObjectMessage.operation` should be applied to this object based on a serial value - `(RTLO4a1)` Expects the following arguments: - `(RTLO4a1a)` `ObjectMessage` @@ -933,9 +933,6 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO19d2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the [RTLO4b4](#RTLO4b4) emission that triggered this event is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted - `(RTPO19e)` Adds a subscription to the `RealtimeObject`'s `PathObjectSubscriptionRegister` ([RTO24](#RTO24)) with subscribed path equal to this `PathObject`'s `path` (per [RTPO2a](#RTPO2a)), the provided `listener`, and the provided `options.depth` - `(RTPO19f)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status -- `(RTPO20)` `PathObject#unsubscribe` function: - - `(RTPO20a)` Accepts a `listener` argument and deregisters it from receiving further events for this `PathObject`'s path - - `(RTPO20b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status ### Instance @@ -1005,9 +1002,6 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS16e)` Returns a [`Subscription`](../features#SUB1) object - `(RTINS16f)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the tree - `(RTINS16g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status -- `(RTINS17)` `Instance#unsubscribe` function: - - `(RTINS17a)` Accepts a `listener` argument and deregisters it from receiving further events using `LiveObject#unsubscribe` ([RTLO4c](#RTLO4c)) - - `(RTINS17b)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status ### PublicAPI::ObjectMessage @@ -1089,7 +1083,6 @@ Types and their properties/methods are public and exposed to users by default. A canApplyOperation(ObjectMessage) -> Boolean // RTLO4a tombstone(ObjectMessage) // RTLO4e subscribe((LiveObjectUpdate) ->) -> Subscription // RTLO4b - unsubscribe((LiveObjectUpdate) ->) // RTLO4c interface LiveObjectUpdate: // RTLO4b4, internal update: Object // RTLO4b4a @@ -1174,7 +1167,6 @@ Types and their properties/methods are public and exposed to users by default. A increment(Number amount?) => io // RTPO17 decrement(Number amount?) => io // RTPO18 subscribe((PathObjectSubscriptionEvent) -> listener, PathObjectSubscriptionOptions? options) -> Subscription // RTPO19 - unsubscribe((PathObjectSubscriptionEvent) -> listener) // RTPO20 class Instance: // RTINS* id: String? // RTINS3 @@ -1191,4 +1183,3 @@ Types and their properties/methods are public and exposed to users by default. A increment(Number amount?) => io // RTINS14 decrement(Number amount?) => io // RTINS15 subscribe((InstanceSubscriptionEvent) -> listener) -> Subscription // RTINS16 - unsubscribe((InstanceSubscriptionEvent) -> listener) // RTINS17 From 2adff89379a633be5fc0042d3510233f210519ec Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 09:27:00 -0300 Subject: [PATCH 20/43] Specify that Subscription#unsubscribe is idempotent Adds SUB2b clarifying that repeated calls to `Subscription#unsubscribe` are a no-op, matching the ably-js implementation across all three subscription factories (LiveObject EventEmitter.off, the PathObjectSubscriptionRegister Map.delete, and Instance which delegates to LiveObject). Lifted from Sachin's spec-alignment PR [1]. [1] https://github.com/ably/specification/pull/480 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/features.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specifications/features.md b/specifications/features.md index 9ccb41f0b..0282218bb 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1875,6 +1875,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(SUB1)` A `Subscription` represents a registration for receiving events from a subscribe operation - `(SUB2)` The `Subscription` object has the following method: - `(SUB2a)` `unsubscribe` - deregisters the listener that was registered by the corresponding `subscribe` call. Once `unsubscribe` is called, the listener must not be called for any subsequent events + - `(SUB2b)` Calling `unsubscribe` more than once is a no-op ### Option types {#options} From cb180a94ed44ec0a87692e9f1b8cea861d2e99e8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 09:29:51 -0300 Subject: [PATCH 21/43] Add internal *ValueType backing fields to the IDL The internal `count` (RTLCV2a) and `entries` (RTLMV2a) properties were already specified in prose but missing from the IDL block. Add them, matching the private `_count` and `_entries` fields on ably-js's `LiveCounterValueType` and `LiveMapValueType`. Lifted from Sachin's spec-alignment PR [1]. [1] https://github.com/ably/specification/pull/480 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 89f5ad624..813c1bc50 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -1111,9 +1111,11 @@ Types and their properties/methods are public and exposed to users by default. A update: Dict // RTLM18b class LiveCounterValueType: // RTLCV* + count: Number // RTLCV2a, internal static create(Number initialCount?) -> LiveCounterValueType // RTLCV3 class LiveMapValueType: // RTLMV* + entries: Dict? // RTLMV2a, internal static create(Dict entries?) -> LiveMapValueType // RTLMV3 interface PathObjectSubscriptionEvent: // RTPO19d From 102fbbc796f5ed9be635f4f90e6ca41454024678 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 10:50:17 -0300 Subject: [PATCH 22/43] Add placeholder RTLO3f for parentReferences Stubs out the new `parentReferences` internal property on `LiveObject`, needed by `getFullPaths` (RTLO4f). The detailed maintenance rules (across `MAP_SET`, `MAP_REMOVE`, `MAP_CLEAR`, `LiveMap` tombstoning, and post-sync rebuild) are deferred to a follow-up by Sachin; the in-progress draft is at [1]. ably-js stores `parentReferences` as a map keyed by a direct `LiveMap` reference; the placeholder instead keys by `objectId`, for consistency with how the rest of the LiveObjects spec models inter-object references (forward references in `LiveMap` entries are already objectIds resolved via the `ObjectsPool` on demand). This is also load-bearing for languages without automatic cycle collection. The protocol allows cyclic `LiveMap` graphs (e.g. `A.x = B`, `B.y = A`), and `getFullPaths` is being specified to handle them; under ARC in Swift, direct parent references in such a cycle would form an unbreakable retain cycle on the two `LiveMap`s. Keying by `objectId` lets the `ObjectsPool` remain the single owner and sidesteps the issue. Implementations remain explicitly permitted to store a direct `LiveMap` reference if more idiomatic in their language -- e.g. to avoid an `ObjectsPool` lookup at each `getFullPaths` traversal step -- as ably-js does today, provided they handle the cycle concern. [1] https://github.com/ably/specification/pull/480 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 813c1bc50..debd4a86b 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -323,6 +323,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO3d1)` Set to `false` when the `LiveObject` is initialized - `(RTLO3e)` protected `tombstonedAt` (optional) Time - a timestamp indicating when this object was tombstoned. This property is nullable, and specification points that manipulate this value maintain the invariant that it is non-null if and only if `isTombstone` is `true` - `(RTLO3e1)` Set to undefined/null when the `LiveObject` is initialized + - `(RTLO3f)` protected `parentReferences` `Dict>` - tracks which `LiveMap`s in the local `ObjectsPool` currently reference this `LiveObject`, and at which keys they do so. The mapping is keyed by the parent `LiveMap`'s `objectId`, with each value being the set of keys at which that `LiveMap` references this `LiveObject`. Used by `getFullPaths` ([RTLO4f](#RTLO4f)) to determine every path the object currently occupies in the LiveObjects tree + - `(RTLO3f1)` This mapping is keyed by `objectId` for consistency with the rest of the LiveObjects spec, where references between objects are stored as `objectId`s and resolved via the `ObjectsPool` on demand. Implementations may store a direct reference to the parent `LiveMap` instead — for example to avoid an `ObjectsPool` lookup at each step of `getFullPaths` ([RTLO4f](#RTLO4f)) traversal — provided the observable behaviour is unchanged. Such implementations should be aware that this may introduce reference cycles between `LiveMap`s, and must ensure this does not cause memory leaks + - `(RTLO3f2)` TODO: The detailed maintenance rules for `parentReferences` (across `MAP_SET`, `MAP_REMOVE`, `MAP_CLEAR`, `LiveMap` tombstoning, and post-sync rebuild) are to be specified by Sachin in a follow-up; see https://github.com/ably/specification/pull/480 for the in-progress draft - `(RTLO4)` `LiveObject` methods: - `(RTLO4b)` `subscribe` - subscribes a user to data updates on this `LiveObject` instance - `(RTLO4b1)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) @@ -1080,6 +1083,7 @@ Types and their properties/methods are public and exposed to users by default. A createOperationIsMerged: Boolean // RTLO3c isTombstone: Boolean // RTLO3d tombstonedAt: Time? // RTLO3e + parentReferences: Dict> // RTLO3f canApplyOperation(ObjectMessage) -> Boolean // RTLO4a tombstone(ObjectMessage) // RTLO4e subscribe((LiveObjectUpdate) ->) -> Subscription // RTLO4b From 853a728f009c80a4c305a96ff861e31561cf4a2a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 11:02:47 -0300 Subject: [PATCH 23/43] Add `getFullPaths` from PR #480 (unreviewed) Lifts the `getFullPaths` definition verbatim from commit ecf85df of Sachin's spec-alignment PR [1]. The only departure from the source is renumbering: Sachin places `getFullPaths` under `RTLO3 LiveObject properties` as `RTLO3g`; this commit places it under `RTLO4 LiveObject methods` as `RTLO4f` (with sub-clauses `RTLO4f1`-`RTLO4f7`) since `getFullPaths` is a function, not a property. Cross-references in RTO24b1 and RTLO3f are updated to match. Lawrence has not reviewed the lifted content yet; the imported clauses retain Sachin's capitalised RFC 2119 keywords and the NetworkX references, both of which may be tightened in follow-up commits. [1] https://github.com/ably/specification/pull/480 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index debd4a86b..00cd83c41 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -368,8 +368,14 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e3b)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLO4e3b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1) - `(RTLO4e4)` Set the `data` attribute of the `LiveObject` to the value described in [RTLC4b](#RTLC4b) or [RTLM4c](#RTLM4c), depending on the object type - - `(RTLO4f)` protected `getFullPaths` - returns the set of paths in the LiveObjects tree at which this `LiveObject` is currently located. Each path is an ordered list of string segments from the root `LiveMap`. The same `LiveObject` may be reachable from multiple paths (e.g. when it is referenced by more than one map entry) or from zero paths (e.g. when it is not a descendant of the root). Used by path-based subscription dispatch ([RTO24b](#RTO24b)) - - `(RTLO4f1)` TODO: The procedure for computing the set of paths is to be specified separately + - `(RTLO4f)` internal `getFullPaths` function - returns the list of distinct paths from the root `LiveMap` (objectId `root`) to this `LiveObject`, computed by traversing `parentReferences` upward. Each returned path is an ordered sequence of keys from `root` to this `LiveObject`. + - `(RTLO4f1)` `getFullPaths` MUST be implemented as an enumeration of all *simple paths* from this `LiveObject` to the root `LiveMap` over the inverse of the `parentReferences` graph (i.e. walking child → parent). A *simple path* is a path along which no `LiveObject` appears more than once. This is the standard graph problem, typically solved by a depth-first traversal with path-local backtracking equivalent to NetworkX's `all_simple_paths`. Implementation should choose iterative DFS with explicit stack (easier to read and debugging). + - `(RTLO4f2)` If this `LiveObject` is the root `LiveMap` (objectId `root`), the returned list MUST contain exactly one path, and that path MUST be empty (zero key segments). This makes the root reachable from itself via the empty key sequence + - `(RTLO4f3)` If this `LiveObject` is not the root `LiveMap` and has no entries in its `parentReferences` at the time of the call (e.g. orphaned, or not yet reachable from root), the returned list MUST be empty + - `(RTLO4f4)` While traversing paths, suppress cyclic paths whenever a sibling branch had already revisited the same node. Reference behaviour on cyclic graphs is given by NetworkX's `all_simple_paths`, which implementations MAY consult for worked examples + - `(RTLO4f5)` When a single parent `LiveMap` references this `LiveObject` at multiple keys, the returned list MUST contain one distinct path per such key, each ending at the corresponding key + - `(RTLO4f6)` When this `LiveObject` is reachable via multiple distinct ancestor paths (either because it has multiple parents in `parentReferences`, or because any ancestor on the way to root itself has multiple paths to root), the returned list MUST contain one path per distinct ancestor path + - `(RTLO4f7)` The order of paths in the returned list is not mandatory. Implementations MAY return paths in any order; callers requiring a stable order MUST sort the result themselves - `(RTLO5)` An `OBJECT_DELETE` operation can be applied to a `LiveObject` in the following way: - `(RTLO5a)` Expects the following arguments: - `(RTLO5a1)` `ObjectMessage` From f432a99b017aa728e0e92e069dda47dd60dadbdc Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 13:52:57 -0300 Subject: [PATCH 24/43] Replace "outermost" with "final element" in *_CREATE references The term "outermost" was unclear in RTLM20e7g2 and RTLMV4d2. Replace it with "final element in the list/array", leveraging RTLMV4k's ordering guarantee that the value type's own MAP_CREATE comes last in the returned array. RTLM20e7g1 is also tweaked to explicitly normalise both branches (RTLCV4 returns a single ObjectMessage; RTLMV4 returns an array) into an ordered list, so that RTLM20e7g2's "final element" wording applies uniformly for both LiveCounterValueType and LiveMapValueType. Addresses [1] and [2]. [1] https://github.com/ably/specification/pull/427#discussion_r3259042315 [2] https://github.com/ably/specification/pull/427#discussion_r3261200723 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 00cd83c41..bf04dae24 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -571,8 +571,8 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM20e7)` Set `ObjectMessage.operation.mapSet.value` depending on the type of the provided `value`: - `(RTLM20e7a)` This clause has been replaced by [RTLM20e7g](#RTLM20e7g). - `(RTLM20e7g)` If the `value` is of type `LiveCounterValueType` or `LiveMapValueType`: - - `(RTLM20e7g1)` Evaluate the value type per [RTLCV4](#RTLCV4) or [RTLMV4](#RTLMV4) respectively to generate `*_CREATE` `ObjectMessages`. Collect all generated `ObjectMessages` - - `(RTLM20e7g2)` Set `ObjectMessage.operation.mapSet.value.objectId` to the `objectId` from the outermost `*_CREATE` `ObjectMessage` + - `(RTLM20e7g1)` Evaluate the value type per [RTLCV4](#RTLCV4) or [RTLMV4](#RTLMV4) respectively. Collect all generated `ObjectMessages` into an ordered list — for [RTLCV4](#RTLCV4) the list contains the single returned `ObjectMessage`; for [RTLMV4](#RTLMV4) the list is the returned array + - `(RTLM20e7g2)` Set `ObjectMessage.operation.mapSet.value.objectId` to the `objectId` from the final `ObjectMessage` in the list gathered in [`RTLM20e7g1`](#RTLM20e7g1) - `(RTLM20e7b)` If the `value` is of type `JsonArray` or `JsonObject`, set `ObjectMessage.operation.mapSet.value.json` to that value - `(RTLM20e7c)` If the `value` is of type `String`, set `ObjectMessage.operation.mapSet.value.string` to that value - `(RTLM20e7d)` If the `value` is of type `Number`, set `ObjectMessage.operation.mapSet.value.number` to that value @@ -803,7 +803,7 @@ A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` obje - `(RTLMV4c)` If any of the values in the internal `entries` are not of an expected type, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40013, indicating that such data type is unsupported - `(RTLMV4d)` Build entries for the `MapCreate` object. For each key-value pair in the internal `entries` (if present), create an `ObjectsMapEntry` for the value: - `(RTLMV4d1)` If the value is of type `LiveCounterValueType`, evaluate it per [RTLCV4](#RTLCV4) to generate a `COUNTER_CREATE` `ObjectMessage`. Collect the generated `ObjectMessage` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the `ObjectMessage` - - `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively evaluate it per [RTLMV4](#RTLMV4) to generate `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the outermost `MAP_CREATE` `ObjectMessage` + - `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively evaluate it per [RTLMV4](#RTLMV4) to generate an ordered array of `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the final `ObjectMessage` in the array - `(RTLMV4d3)` If the value is of type `JsonArray` or `JsonObject`, set `ObjectsMapEntry.data.json` to that value - `(RTLMV4d4)` If the value is of type `String`, set `ObjectsMapEntry.data.string` to that value - `(RTLMV4d5)` If the value is of type `Number`, set `ObjectsMapEntry.data.number` to that value From f3fcbc144ae94f5b6dc669fec010e427dea1e3cd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 14:22:48 -0300 Subject: [PATCH 25/43] Mark PAOM2c `connectionId` as optional This was a transcription error in d4662fa8, which intended to base the `PublicAPI::ObjectMessage` (PAOM2) field types on ably-js's public `ObjectMessage` type in `liveobjects.d.ts`. That type has `connectionId?: string` (optional), but PAOM2c was written as required. Fix both the prose and the IDL to mark `connectionId` as optional, matching ably-js. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index bf04dae24..185b2cf12 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -1018,7 +1018,7 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(PAOM2)` The attributes available in a `PublicAPI::ObjectMessage` are: - `(PAOM2a)` `id` string - the `id` ([OM2a](../features#OM2a)) of the source `ObjectMessage` - `(PAOM2b)` `clientId` string (optional) - the `clientId` ([OM2b](../features#OM2b)) of the source `ObjectMessage` - - `(PAOM2c)` `connectionId` string - the `connectionId` ([OM2c](../features#OM2c)) of the source `ObjectMessage` + - `(PAOM2c)` `connectionId` string (optional) - the `connectionId` ([OM2c](../features#OM2c)) of the source `ObjectMessage` - `(PAOM2d)` `timestamp` Time - the `timestamp` ([OM2e](../features#OM2e)) of the source `ObjectMessage` - `(PAOM2e)` `channel` string - the name of the channel on which the source `ObjectMessage` was received - `(PAOM2f)` `operation` `PublicAPI::ObjectOperation` ([PAOOP1](#PAOOP1)) - a `PublicAPI::ObjectOperation` derived per [PAOOP3](#PAOOP3) from the `operation` ([OM2f](../features#OM2f)) of the source `ObjectMessage` @@ -1142,7 +1142,7 @@ Types and their properties/methods are public and exposed to users by default. A class PublicAPI::ObjectMessage: // PAOM* id: String // PAOM2a clientId: String? // PAOM2b - connectionId: String // PAOM2c + connectionId: String? // PAOM2c timestamp: Time // PAOM2d channel: String // PAOM2e operation: PublicAPI::ObjectOperation // PAOM2f From 0202c05e062479ea32ff6737a09ccd26850c5efd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 14:26:51 -0300 Subject: [PATCH 26/43] Make PAOM3's operation precondition explicit PAOM3 constructs a `PublicAPI::ObjectMessage` from a source `ObjectMessage`, and references the source's `operation` field (both directly in PAOM3d and transitively via PAOOP3, which expects an `ObjectOperation`). All three call sites (RTO24b2b2, RTPO19d2, RTINS16d2) already gate the call on `operation` being populated, and PAOM1 frames the type as the user-facing representation of an `ObjectMessage` "that carried an operation", but the procedure itself didn't state the precondition. Add a PAOM3a "Preconditions" subclause stating that callers must ensure the source has its `operation` field populated, and shift the existing steps to PAOM3b-d. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 185b2cf12..bbf237499 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -1027,9 +1027,11 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(PAOM2i)` `siteCode` string (optional) - the `siteCode` ([OM2i](../features#OM2i)) of the source `ObjectMessage` - `(PAOM2j)` `extras` JSON-encodable object (optional) - the `extras` ([OM2d](../features#OM2d)) of the source `ObjectMessage` - `(PAOM3)` To construct a `PublicAPI::ObjectMessage` from a source `ObjectMessage` received on a channel `channel`: - - `(PAOM3a)` Set the `channel` attribute to `channel.name` - - `(PAOM3b)` Copy `id`, `clientId`, `connectionId`, `timestamp`, `serial`, `serialTimestamp`, `siteCode`, and `extras` from the source `ObjectMessage` to the corresponding attributes of the `PublicAPI::ObjectMessage` - - `(PAOM3c)` Set `operation` to a `PublicAPI::ObjectOperation` derived per [PAOOP3](#PAOOP3) from the `operation` ([OM2f](../features#OM2f)) of the source `ObjectMessage` + - `(PAOM3a)` Preconditions (callers are responsible for ensuring these): + - `(PAOM3a1)` The source `ObjectMessage` has its `operation` ([OM2f](../features#OM2f)) field populated + - `(PAOM3b)` Set the `channel` attribute to `channel.name` + - `(PAOM3c)` Copy `id`, `clientId`, `connectionId`, `timestamp`, `serial`, `serialTimestamp`, `siteCode`, and `extras` from the source `ObjectMessage` to the corresponding attributes of the `PublicAPI::ObjectMessage` + - `(PAOM3d)` Set `operation` to a `PublicAPI::ObjectOperation` derived per [PAOOP3](#PAOOP3) from the `operation` ([OM2f](../features#OM2f)) of the source `ObjectMessage` ### PublicAPI::ObjectOperation From 2ec6a5f5f5853a528fba9dc613de0056afbfa2f5 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 15:15:59 -0300 Subject: [PATCH 27/43] Mark PAOM2a `id` and PAOM2d `timestamp` as optional These values are not populated for `ObjectMessage`s created by apply-on-ACK (RTO20d2). Matches the corresponding change in ably-js#2230. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index bbf237499..7c0b88882 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -1016,10 +1016,10 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(PAOM1)` A `PublicAPI::ObjectMessage` is the user-facing representation of an inbound `ObjectMessage` ([OM1](../features#OM1)) that carried an operation. It is delivered to user subscription listeners (see [RTPO19d2](#RTPO19d2), [RTINS16d2](#RTINS16d2)) so that user code can inspect the metadata of the message that triggered an object change. The `PublicAPI::` prefix is used to avoid a name clash with `ObjectMessage`; SDKs expose this type to users as `ObjectMessage`. - `(PAOM2)` The attributes available in a `PublicAPI::ObjectMessage` are: - - `(PAOM2a)` `id` string - the `id` ([OM2a](../features#OM2a)) of the source `ObjectMessage` + - `(PAOM2a)` `id` string (optional) - the `id` ([OM2a](../features#OM2a)) of the source `ObjectMessage` - `(PAOM2b)` `clientId` string (optional) - the `clientId` ([OM2b](../features#OM2b)) of the source `ObjectMessage` - `(PAOM2c)` `connectionId` string (optional) - the `connectionId` ([OM2c](../features#OM2c)) of the source `ObjectMessage` - - `(PAOM2d)` `timestamp` Time - the `timestamp` ([OM2e](../features#OM2e)) of the source `ObjectMessage` + - `(PAOM2d)` `timestamp` Time (optional) - the `timestamp` ([OM2e](../features#OM2e)) of the source `ObjectMessage` - `(PAOM2e)` `channel` string - the name of the channel on which the source `ObjectMessage` was received - `(PAOM2f)` `operation` `PublicAPI::ObjectOperation` ([PAOOP1](#PAOOP1)) - a `PublicAPI::ObjectOperation` derived per [PAOOP3](#PAOOP3) from the `operation` ([OM2f](../features#OM2f)) of the source `ObjectMessage` - `(PAOM2g)` `serial` string (optional) - the `serial` ([OM2h](../features#OM2h)) of the source `ObjectMessage` @@ -1142,10 +1142,10 @@ Types and their properties/methods are public and exposed to users by default. A message: PublicAPI::ObjectMessage? // RTINS16d2 class PublicAPI::ObjectMessage: // PAOM* - id: String // PAOM2a + id: String? // PAOM2a clientId: String? // PAOM2b connectionId: String? // PAOM2c - timestamp: Time // PAOM2d + timestamp: Time? // PAOM2d channel: String // PAOM2e operation: PublicAPI::ObjectOperation // PAOM2f serial: String? // PAOM2g From 33b9832c00874c09abd52566f61a2af7fee45af8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 15:04:58 -0300 Subject: [PATCH 28/43] Align LiveObject tombstone behaviour with ably-js PR #480 [1] proposed specifying that ably-js deregisters all LiveObject#subscribe listeners on tombstone. Adopt that proposal with refined wording and a new LiveObjectUpdate.tombstone field that makes the trigger condition explicit. Also add the related ably-js refactor (commit 1d98cc3 [2]) that has tombstone() return the cleared LiveObjectUpdate rather than dispatching it inline. [1] https://github.com/ably/specification/pull/480 [2] https://github.com/ably/ably-js/commit/1d98cc3 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 7c0b88882..3ccc4f990 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -335,12 +335,15 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4b4a)` `LiveObjectUpdate.update` contains the specific information about what was changed on the object. The exact type depends on the object type - `(RTLO4b4b)` The `LiveObjectUpdate.noop` internal property can be used to indicate that the update was a no-op - `(RTLO4b4d)` `LiveObjectUpdate.objectMessage` is an optional `ObjectMessage` - the source `ObjectMessage` that caused this update, if any + - `(RTLO4b4e)` The `LiveObjectUpdate.tombstone` internal Boolean property indicates that this update was emitted as a result of this `LiveObject` being tombstoned. It defaults to `false` if not explicitly set - `(RTLO4b4c)` When a `LiveObjectUpdate` is emitted: - `(RTLO4b4c1)` If `LiveObjectUpdate` is indicated to be a no-op, do nothing - `(RTLO4b4c2)` This clause has been replaced by [RTLO4b4c3](#RTLO4b4c3) as of specification version 6.0.0. - `(RTLO4b4c3)` Otherwise: - `(RTLO4b4c3a)` The registered listener of each subscription created via `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) on this `LiveObject` is called with the `LiveObjectUpdate` - `(RTLO4b4c3b)` Perform path-based subscription dispatch as described in [RTO24b](#RTO24b), passing this `LiveObject` and the `LiveObjectUpdate` + - `(RTLO4b4c3c)` If `LiveObjectUpdate.tombstone` is `true`, after [RTLO4b4c3a](#RTLO4b4c3a) has completed, the library must deregister all listeners on this `LiveObject` that were registered via `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) + - `(RTLO4b4c3c1)` (non-normative) Path-based subscriptions ([RTPO19](#RTPO19)) are unaffected, because their lifetime is tied to the path rather than to this `LiveObject` instance - `(RTLO4b5)` This clause has been replaced by [RTLO4b7](#RTLO4b7) - `(RTLO4b5a)` This clause has been replaced by [RTLO4b7](#RTLO4b7) - `(RTLO4b5b)` This clause has been replaced by [RTLO4b7](#RTLO4b7) @@ -368,6 +371,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e3b)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLO4e3b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1) - `(RTLO4e4)` Set the `data` attribute of the `LiveObject` to the value described in [RTLC4b](#RTLC4b) or [RTLM4c](#RTLM4c), depending on the object type + - `(RTLO4e5)` Compute a `LiveObjectUpdate` representing the data change resulting from this `LiveObject` being tombstoned, by calculating the diff between the `data` value from before [RTLO4e4](#RTLO4e4) was applied (as `previousData`) and the current `data` value (as `newData`), per [RTLC14](#RTLC14) or [RTLM22](#RTLM22), depending on the object type + - `(RTLO4e6)` Set `LiveObjectUpdate.tombstone` to `true` on the object computed in [RTLO4e5](#RTLO4e5) + - `(RTLO4e7)` Set `LiveObjectUpdate.objectMessage` on the object computed in [RTLO4e5](#RTLO4e5) to the `ObjectMessage` argument + - `(RTLO4e8)` Return the `LiveObjectUpdate` object computed in [RTLO4e5](#RTLO4e5) - `(RTLO4f)` internal `getFullPaths` function - returns the list of distinct paths from the root `LiveMap` (objectId `root`) to this `LiveObject`, computed by traversing `parentReferences` upward. Each returned path is an ordered sequence of keys from `root` to this `LiveObject`. - `(RTLO4f1)` `getFullPaths` MUST be implemented as an enumeration of all *simple paths* from this `LiveObject` to the root `LiveMap` over the inverse of the `parentReferences` graph (i.e. walking child → parent). A *simple path* is a path along which no `LiveObject` appears more than once. This is the standard graph problem, typically solved by a depth-first traversal with path-local backtracking equivalent to NetworkX's `all_simple_paths`. Implementation should choose iterative DFS with explicit stack (easier to read and debugging). - `(RTLO4f2)` If this `LiveObject` is the root `LiveMap` (objectId `root`), the returned list MUST contain exactly one path, and that path MUST be empty (zero key segments). This makes the root reachable from itself via the empty key sequence @@ -380,6 +387,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO5a)` Expects the following arguments: - `(RTLO5a1)` `ObjectMessage` - `(RTLO5b)` Tombstone the current `LiveObject` using [`LiveObject.tombstone`](#RTLO4e), passing in the `ObjectMessage` + - `(RTLO5c)` Return the `LiveObjectUpdate` returned by the `LiveObject.tombstone` call performed in [RTLO5b](#RTLO5b) - `(RTLO6)` A `tombstonedAt` value can be calculated from a provided `serialTimestamp` as follows: - `(RTLO6a)` It is equal to `serialTimestamp` if it exists - `(RTLO6b)` Otherwise, it is equal to the current time using the local clock @@ -425,7 +433,8 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLC6e)` If `LiveCounter.isTombstone` is `true`, finish processing the `ObjectState` - `(RTLC6e1)` Return a `LiveCounterUpdate` object with `LiveCounterUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLC6f)` If `ObjectState.tombstone` is `true`, tombstone the current `LiveCounter` using [`LiveObject.tombstone`](#RTLO4e), passing in the `ObjectMessage`. Finish processing the `ObjectState` - - `(RTLC6f1)` Return a `LiveCounterUpdate` object with `LiveCounterUpdate.update.amount` set to the negative `data` value that this `LiveCounter` had before being tombstoned, and `LiveCounterUpdate.objectMessage` set to the provided `ObjectMessage` + - `(RTLC6f1)` This clause has been replaced by [RTLC6f2](#RTLC6f2) as of specification version 6.0.0. + - `(RTLC6f2)` Return the `LiveCounterUpdate` returned by the `LiveObject.tombstone` call performed in [RTLC6f](#RTLC6f) - `(RTLC6g)` Store the current `data` value as `previousData` for use in [RTLC6h](#RTLC6h) - `(RTLC6b)` Set the private flag `createOperationIsMerged` to `false` - `(RTLC6c)` Set `data` to the value of `ObjectState.counter.count`, or to 0 if it does not exist @@ -453,7 +462,8 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLC7d5a)` Emit the `LiveCounterUpdate` object returned as a result of applying the operation - `(RTLC7d5b)` Return `true` - `(RTLC7d4)` If `ObjectMessage.operation.action` is set to `OBJECT_DELETE`, apply the operation as described in [RTLO5](#RTLO5), passing in `ObjectMessage` - - `(RTLC7d4a)` Emit a `LiveCounterUpdate` object after applying the `OBJECT_DELETE` operation, with `LiveCounterUpdate.update.amount` set to the negated value that this `LiveCounter` held before the operation was applied and `LiveCounterUpdate.objectMessage` set to `ObjectMessage` + - `(RTLC7d4a)` This clause has been replaced by [RTLC7d4c](#RTLC7d4c) as of specification version 6.0.0. + - `(RTLC7d4c)` Emit the `LiveCounterUpdate` object returned as a result of applying the operation - `(RTLC7d4b)` Return `true` - `(RTLC7d3)` Otherwise, log a warning that an object operation message with an unsupported action has been received, and discard the current `ObjectMessage` without taking any further action. No data update event is emitted. Return `false` - `(RTLC8)` A `COUNTER_CREATE` operation can be applied to a `LiveCounter` in the following way: @@ -606,7 +616,8 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM6e)` If `LiveMap.isTombstone` is `true`, finish processing the `ObjectState` - `(RTLM6e1)` Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM6f)` If `ObjectState.tombstone` is `true`, tombstone the current `LiveMap` using [`LiveObject.tombstone`](#RTLO4e), passing in the `ObjectMessage`. Finish processing the `ObjectState` - - `(RTLM6f1)` Return a `LiveMapUpdate` object with `LiveMapUpdate.update` consisting of entries for the keys that were removed as a result of the object being tombstoned, each set to `removed`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` + - `(RTLM6f1)` This clause has been replaced by [RTLM6f2](#RTLM6f2) as of specification version 6.0.0. + - `(RTLM6f2)` Return the `LiveMapUpdate` returned by the `LiveObject.tombstone` call performed in [RTLM6f](#RTLM6f) - `(RTLM6g)` Store the current `data` value as `previousData` for use in [RTLM6h](#RTLM6h) - `(RTLM6b)` Set the private flag `createOperationIsMerged` to `false` - `(RTLM6i)` Set the private `clearTimeserial` to `ObjectState.map.clearTimeserial`, or to `null` if not provided @@ -647,7 +658,8 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM15d7a)` Emit the `LiveMapUpdate` object returned as a result of applying the operation - `(RTLM15d7b)` Return `true` - `(RTLM15d5)` If `ObjectMessage.operation.action` is set to `OBJECT_DELETE`, apply the operation as described in [RTLO5](#RTLO5), passing in `ObjectMessage` - - `(RTLM15d5a)` Emit a `LiveMapUpdate` object with `LiveMapUpdate.update` consisting of entries for the keys that were removed as a result of applying the `OBJECT_DELETE` operation, each set to `removed`, and `LiveMapUpdate.objectMessage` set to `ObjectMessage` + - `(RTLM15d5a)` This clause has been replaced by [RTLM15d5c](#RTLM15d5c) as of specification version 6.0.0. + - `(RTLM15d5c)` Emit the `LiveMapUpdate` object returned as a result of applying the operation - `(RTLM15d5b)` Return `true` - `(RTLM15d8)` If `ObjectMessage.operation.action` is set to `MAP_CLEAR`, apply the operation as described in [RTLM24](#RTLM24), passing in `ObjectMessage.serial` and `ObjectMessage` - `(RTLM15d8a)` Emit the `LiveMapUpdate` object returned as a result of applying the operation @@ -1093,13 +1105,14 @@ Types and their properties/methods are public and exposed to users by default. A tombstonedAt: Time? // RTLO3e parentReferences: Dict> // RTLO3f canApplyOperation(ObjectMessage) -> Boolean // RTLO4a - tombstone(ObjectMessage) // RTLO4e + tombstone(ObjectMessage) -> LiveObjectUpdate // RTLO4e subscribe((LiveObjectUpdate) ->) -> Subscription // RTLO4b interface LiveObjectUpdate: // RTLO4b4, internal update: Object // RTLO4b4a noop: Boolean // RTLO4b4b objectMessage: ObjectMessage? // RTLO4b4d + tombstone: Boolean // RTLO4b4e class LiveCounter extends LiveObject: // RTLC*, RTLC1, internal value() -> Number // RTLC5 From 54a3a02c11850631f9a87899a35af9aa682429e7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:00:29 -0300 Subject: [PATCH 29/43] Pull parentReferences maintenance rules from PR #480 Imports the parentReferences bookkeeping spec from PR #480 [1] onto this integration branch, resolving the committed conflict marker at RTLO3f and the duplicate clause IDs introduced by the import. Imported from #480 verbatim: - RTO5c10: post-sync rebuild of every parentReferences map. - addParentReference and removeParentReference internal methods, with set-merge / set-remove / empty-set-delete semantics. - Tombstone-time children walk for LiveMap, stripping parent references from each referenced child before the data is cleared. - MAP_SET, MAP_REMOVE and MAP_CLEAR parent-reference maintenance (RTLM7a3, RTLM7i, RTLM8a3, RTLM24e1c). - IDL declarations for the two new internal methods. The Primitive type alias added in #480 was deliberately not imported, as it is unrelated to the parentReferences work. Conflicts reconciled: - The committed <<<<<<< / >>>>>>> block at RTLO3f. Kept the objectId-keyed Dict> description from this branch (consistent with #480's own IDL line and its set-style manipulation contracts; the alternative half mandated a specific in-memory representation that ably-js does not match literally). The "set to an empty map on initialisation" clause from #480 was moved to RTLO3f2; the prior RTLO3f2 TODO is deleted, since the imported maintenance rules now resolve it. RTO5c10a's back-reference was updated to point at the new RTLO3f2. - Duplicate clause IDs introduced by #480 were renamed per the "rename the later addition" convention in CONTRIBUTING.md: - addParentReference: RTLO4f -> RTLO4g - removeParentReference: RTLO4g -> RTLO4h - tombstone children walk: RTLO4e5* -> RTLO4e9* All cross-references to the renamed clauses were updated accordingly. The pre-existing RTLO4f (getFullPaths) and RTLO4e5-e8 (Compute LiveObjectUpdate through Return) are untouched. Linter passes. Still needs human review. [1] https://github.com/ably/specification/pull/480 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 31 +++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 3ccc4f990..75f1c47ef 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -174,6 +174,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO5c1b1c)` This clause has been deleted (redundant to [RTO5f3](#RTO5f3)). - `(RTO5c2)` Remove any objects from the internal `ObjectsPool` for which `objectId`s were not received during the sync sequence - `(RTO5c2a)` The object with ID `root` must not be removed from `ObjectsPool`, as per [RTO3b](#RTO3b) + - `(RTO5c10)` After re-establishing the `ObjectsPool` per [RTO5c1](#RTO5c1) and [RTO5c2](#RTO5c2), the client MUST rebuild every `parentReferences` map ([RTLO3f](#RTLO3f)). Specifically: + - `(RTO5c10a)` For each `LiveObject` in the internal `ObjectsPool` ([RTO3](#RTO3)), reset its `parentReferences` to an empty map as defined in [RTLO3f2](#RTLO3f2) + - `(RTO5c10b)` After [RTO5c10a](#RTO5c10a) has completed, for each `LiveMap` in the internal `ObjectsPool`, iterate its `LiveMap#entries` as per [RTLM11](#RTLM11) + - `(RTO5c10b1)` For each iterated entry whose value type is `LiveObject`, call `addParentReference(parent, key)` on the `LiveObject` (per [RTLO4g](#RTLO4g)), passing the iterated `LiveMap` as `parent` and the iterated entry key as `key` - `(RTO5c7)` For each previously existing object that was updated as a result of [RTO5c1a](#RTO5c1a), emit the corresponding stored `LiveObjectUpdate` object from [RTO5c1a2](#RTO5c1a2) - `(RTO5c6)` `ObjectMessages` stored in the `bufferedObjectOperations` list are applied as described in [RTO9](#RTO9), passing `source` as `CHANNEL` - `(RTO5c3)` Clear any stored sync sequence identifiers and cursor values @@ -325,7 +329,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO3e1)` Set to undefined/null when the `LiveObject` is initialized - `(RTLO3f)` protected `parentReferences` `Dict>` - tracks which `LiveMap`s in the local `ObjectsPool` currently reference this `LiveObject`, and at which keys they do so. The mapping is keyed by the parent `LiveMap`'s `objectId`, with each value being the set of keys at which that `LiveMap` references this `LiveObject`. Used by `getFullPaths` ([RTLO4f](#RTLO4f)) to determine every path the object currently occupies in the LiveObjects tree - `(RTLO3f1)` This mapping is keyed by `objectId` for consistency with the rest of the LiveObjects spec, where references between objects are stored as `objectId`s and resolved via the `ObjectsPool` on demand. Implementations may store a direct reference to the parent `LiveMap` instead — for example to avoid an `ObjectsPool` lookup at each step of `getFullPaths` ([RTLO4f](#RTLO4f)) traversal — provided the observable behaviour is unchanged. Such implementations should be aware that this may introduce reference cycles between `LiveMap`s, and must ensure this does not cause memory leaks - - `(RTLO3f2)` TODO: The detailed maintenance rules for `parentReferences` (across `MAP_SET`, `MAP_REMOVE`, `MAP_CLEAR`, `LiveMap` tombstoning, and post-sync rebuild) are to be specified by Sachin in a follow-up; see https://github.com/ably/specification/pull/480 for the in-progress draft + - `(RTLO3f2)` Set to an empty map when the `LiveObject` is initialized - `(RTLO4)` `LiveObject` methods: - `(RTLO4b)` `subscribe` - subscribes a user to data updates on this `LiveObject` instance - `(RTLO4b1)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) @@ -362,6 +366,13 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4a4)` Get the `siteSerial` value stored for this `LiveObject` in the `siteTimeserials` map using the key `ObjectMessage.siteCode` - `(RTLO4a5)` If the `siteSerial` for this `LiveObject` is null or an empty string, return true - `(RTLO4a6)` If the `siteSerial` for this `LiveObject` is not an empty string, return true if `ObjectMessage.serial` is greater than `siteSerial` when compared lexicographically + - `(RTLO4g)` internal `addParentReference(parent, key)` method - records that the `LiveMap` `parent` references this `LiveObject` at `key` + - `(RTLO4g1)` If `parent` is already present in `parentReferences`, `key` MUST be added to the existing set associated with `parent` + - `(RTLO4g2)` Otherwise, a new entry MUST be inserted into `parentReferences` for `parent` with a set containing only `key` + - `(RTLO4h)` internal `removeParentReference(parent, key)` method - removes the recorded reference from `parent` at `key` + - `(RTLO4h1)` If `parent` is not present in `parentReferences`, the call MUST be a no-op + - `(RTLO4h2)` Otherwise, `key` MUST be removed from the set associated with `parent` + - `(RTLO4h3)` If, as a result of [RTLO4h2](#RTLO4h2), the set associated with `parent` is empty, the `parent` entry MUST be removed from `parentReferences` - `(RTLO4e)` protected `tombstone` - a convenience method used to tombstone this `LiveObject`. The realtime system reserves the right to tombstone an object (i.e. mark it for deletion from the objects pool) by publishing an `OBJECT_DELETE` operation at any time if the object is orphaned (not a descendant of the root object) or remains uninitialized (no `*_CREATE` operation has been received) for an extended period. Only the realtime system may publish an `OBJECT_DELETE` operation; clients must never send it. This method describes the steps the client library must take when it needs to tombstone an object locally. Eventually, tombstoned objects will be garbage collected following the procedure described in [RTO10](#RTO10) - `(RTLO4e1)` Expects the following arguments: - `(RTLO4e1a)` `ObjectMessage` @@ -370,6 +381,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e3a)` This clause has been replaced by [RTLO6a](#RTLO6a) - `(RTLO4e3b)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLO4e3b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1) + - `(RTLO4e9)` If the current `LiveObject` is of type `LiveMap`, then before [RTLO4e4](#RTLO4e4) is applied, do following: + - `(RTLO4e9a)` For each iterated `entry` in current `LiveMap`'s private `data`: + - `(RTLO4e9a1)` If `entry.value.data` have `objectId` as a field, retrieve corresponding child `LiveObject` from `ObjectsPool` using given `objectId` + - `(RTLO4e9a2)` If child `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing current `LiveMap` as `parent` and the iterated `entry.value` as `key` - `(RTLO4e4)` Set the `data` attribute of the `LiveObject` to the value described in [RTLC4b](#RTLC4b) or [RTLM4c](#RTLM4c), depending on the object type - `(RTLO4e5)` Compute a `LiveObjectUpdate` representing the data change resulting from this `LiveObject` being tombstoned, by calculating the diff between the `data` value from before [RTLO4e4](#RTLO4e4) was applied (as `previousData`) and the current `data` value (as `newData`), per [RTLC14](#RTLC14) or [RTLM22](#RTLM22), depending on the object type - `(RTLO4e6)` Set `LiveObjectUpdate.tombstone` to `true` on the object computed in [RTLO4e5](#RTLO4e5) @@ -684,6 +699,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7h)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM7a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: - `(RTLM7a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object + - `(RTLM7a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM7a2](#RTLM7a2e) is applied, the parent reference recorded on existing `ObjectsMapEntry` MUST be removed: + - `(RTLM7a3a)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` + - `(RTLM7a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the operation's key as `key` - `(RTLM7a2)` Otherwise, apply the operation to the existing entry: - `(RTLM7a2a)` This clause has been replaced by [RTLM7a2e](#RTLM7a2e) as of specification version 6.0.0. - `(RTLM7a2e)` Set `ObjectsMapEntry.data` to the `MapSet.value` @@ -699,6 +717,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7c1)` This clause has been replaced by [RTLM7g1](#RTLM7g1) as of specification version 6.0.0. - `(RTLM7g)` If `MapSet.value.objectId` is non-empty: - `(RTLM7g1)` Create a new `LiveObject` for this `objectId` in the internal `ObjectsPool` per [RTO6](#RTO6) + - `(RTLM7i)` A parent reference MUST be recorded on the `LiveObject` newly referenced by this entry (if any): + - `(RTLM7i1)` If `MapSet.value.objectId` is not present, no action is required + - `(RTLM7i2)` Otherwise, call `addParentReference(parent, key)` per [RTLO4g](#RTLO4g) on the `LiveObject` in the local `ObjectsPool` with `objectId` equal to `MapSet.value.objectId` (guaranteed to exist per [RTLM7g](#RTLM7g)), passing this `LiveMap` as `parent` and the operation's key as `key` - `(RTLM7f)` Return a `LiveMapUpdate` object with a `LiveMapUpdate.update` map containing the key used in this operation set to `updated`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLM8)` A `MAP_REMOVE` operation for a key can be applied to a `LiveMap` in the following way: - `(RTLM8c)` Expects the following arguments: @@ -711,6 +732,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM8g)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM8a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: - `(RTLM8a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object + - `(RTLM8a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM8a2](#RTLM8a2) is applied, the parent reference recorded on existing `ObjectsMapEntry` MUST be removed: + - `(RTLM8a3a)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` + - `(RTLM8a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the operation's key as `key` - `(RTLM8a2)` Otherwise, apply the operation to the existing entry: - `(RTLM8a2a)` Set `ObjectsMapEntry.data` to undefined/null - `(RTLM8a2b)` Set `ObjectsMapEntry.timeserial` to the provided `serial` @@ -734,6 +758,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM24d)` Set the private `clearTimeserial` to the provided `serial` - `(RTLM24e)` For each `ObjectsMapEntry` in the internal `data`: - `(RTLM24e1)` If `ObjectsMapEntry.timeserial` is null or omitted, or the `serial` is lexicographically greater than `ObjectsMapEntry.timeserial`: + - `(RTLM24e1c)` If the current `ObjectsMapEntry` is of type `LiveObject`, the parent reference recorded on existing `ObjectsMapEntry` MUST be removed: + - `(RTLM24e1c1)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` + - `(RTLM24e1c2)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the iterated entry key as `key` - `(RTLM24e1a)` Remove the entry from the internal `data` map. The entry is not retained as a tombstone. - `(RTLM24e1b)` Record the key for the `LiveMapUpdate` as `removed` - `(RTLM24f)` Return a `LiveMapUpdate` object with `LiveMapUpdate.update` containing each key recorded in [RTLM24e1b](#RTLM24e1b) set to `removed`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` @@ -1106,6 +1133,8 @@ Types and their properties/methods are public and exposed to users by default. A parentReferences: Dict> // RTLO3f canApplyOperation(ObjectMessage) -> Boolean // RTLO4a tombstone(ObjectMessage) -> LiveObjectUpdate // RTLO4e + addParentReference(parent, key) // RTLO4g + removeParentReference(parent, key) // RTLO4h subscribe((LiveObjectUpdate) ->) -> Subscription // RTLO4b interface LiveObjectUpdate: // RTLO4b4, internal From a08ec0264615c97617a730fd05d921556fcec697 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:05:33 -0300 Subject: [PATCH 30/43] Lowercase RFC 2119 keywords in clauses imported from PR #480 Follow-up to 54a3a02. The clauses pulled in from PR #480 use the uppercase RFC 2119 convention (MUST etc.); lowercase them for consistency with the prose style preferred on this branch. Touches the 10 occurrences of MUST in RTO5c10, RTLO4g1-g2, RTLO4h1-h3, RTLM7a3, RTLM7i, RTLM8a3 and RTLM24e1c. The pre-existing uppercase keywords in RTLO4f1-f7 (getFullPaths) are intentionally left alone. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 75f1c47ef..bd5850836 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -174,7 +174,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO5c1b1c)` This clause has been deleted (redundant to [RTO5f3](#RTO5f3)). - `(RTO5c2)` Remove any objects from the internal `ObjectsPool` for which `objectId`s were not received during the sync sequence - `(RTO5c2a)` The object with ID `root` must not be removed from `ObjectsPool`, as per [RTO3b](#RTO3b) - - `(RTO5c10)` After re-establishing the `ObjectsPool` per [RTO5c1](#RTO5c1) and [RTO5c2](#RTO5c2), the client MUST rebuild every `parentReferences` map ([RTLO3f](#RTLO3f)). Specifically: + - `(RTO5c10)` After re-establishing the `ObjectsPool` per [RTO5c1](#RTO5c1) and [RTO5c2](#RTO5c2), the client must rebuild every `parentReferences` map ([RTLO3f](#RTLO3f)). Specifically: - `(RTO5c10a)` For each `LiveObject` in the internal `ObjectsPool` ([RTO3](#RTO3)), reset its `parentReferences` to an empty map as defined in [RTLO3f2](#RTLO3f2) - `(RTO5c10b)` After [RTO5c10a](#RTO5c10a) has completed, for each `LiveMap` in the internal `ObjectsPool`, iterate its `LiveMap#entries` as per [RTLM11](#RTLM11) - `(RTO5c10b1)` For each iterated entry whose value type is `LiveObject`, call `addParentReference(parent, key)` on the `LiveObject` (per [RTLO4g](#RTLO4g)), passing the iterated `LiveMap` as `parent` and the iterated entry key as `key` @@ -367,12 +367,12 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4a5)` If the `siteSerial` for this `LiveObject` is null or an empty string, return true - `(RTLO4a6)` If the `siteSerial` for this `LiveObject` is not an empty string, return true if `ObjectMessage.serial` is greater than `siteSerial` when compared lexicographically - `(RTLO4g)` internal `addParentReference(parent, key)` method - records that the `LiveMap` `parent` references this `LiveObject` at `key` - - `(RTLO4g1)` If `parent` is already present in `parentReferences`, `key` MUST be added to the existing set associated with `parent` - - `(RTLO4g2)` Otherwise, a new entry MUST be inserted into `parentReferences` for `parent` with a set containing only `key` + - `(RTLO4g1)` If `parent` is already present in `parentReferences`, `key` must be added to the existing set associated with `parent` + - `(RTLO4g2)` Otherwise, a new entry must be inserted into `parentReferences` for `parent` with a set containing only `key` - `(RTLO4h)` internal `removeParentReference(parent, key)` method - removes the recorded reference from `parent` at `key` - - `(RTLO4h1)` If `parent` is not present in `parentReferences`, the call MUST be a no-op - - `(RTLO4h2)` Otherwise, `key` MUST be removed from the set associated with `parent` - - `(RTLO4h3)` If, as a result of [RTLO4h2](#RTLO4h2), the set associated with `parent` is empty, the `parent` entry MUST be removed from `parentReferences` + - `(RTLO4h1)` If `parent` is not present in `parentReferences`, the call must be a no-op + - `(RTLO4h2)` Otherwise, `key` must be removed from the set associated with `parent` + - `(RTLO4h3)` If, as a result of [RTLO4h2](#RTLO4h2), the set associated with `parent` is empty, the `parent` entry must be removed from `parentReferences` - `(RTLO4e)` protected `tombstone` - a convenience method used to tombstone this `LiveObject`. The realtime system reserves the right to tombstone an object (i.e. mark it for deletion from the objects pool) by publishing an `OBJECT_DELETE` operation at any time if the object is orphaned (not a descendant of the root object) or remains uninitialized (no `*_CREATE` operation has been received) for an extended period. Only the realtime system may publish an `OBJECT_DELETE` operation; clients must never send it. This method describes the steps the client library must take when it needs to tombstone an object locally. Eventually, tombstoned objects will be garbage collected following the procedure described in [RTO10](#RTO10) - `(RTLO4e1)` Expects the following arguments: - `(RTLO4e1a)` `ObjectMessage` @@ -699,7 +699,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7h)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM7a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: - `(RTLM7a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLM7a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM7a2](#RTLM7a2e) is applied, the parent reference recorded on existing `ObjectsMapEntry` MUST be removed: + - `(RTLM7a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM7a2](#RTLM7a2e) is applied, the parent reference recorded on existing `ObjectsMapEntry` must be removed: - `(RTLM7a3a)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` - `(RTLM7a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the operation's key as `key` - `(RTLM7a2)` Otherwise, apply the operation to the existing entry: @@ -717,7 +717,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7c1)` This clause has been replaced by [RTLM7g1](#RTLM7g1) as of specification version 6.0.0. - `(RTLM7g)` If `MapSet.value.objectId` is non-empty: - `(RTLM7g1)` Create a new `LiveObject` for this `objectId` in the internal `ObjectsPool` per [RTO6](#RTO6) - - `(RTLM7i)` A parent reference MUST be recorded on the `LiveObject` newly referenced by this entry (if any): + - `(RTLM7i)` A parent reference must be recorded on the `LiveObject` newly referenced by this entry (if any): - `(RTLM7i1)` If `MapSet.value.objectId` is not present, no action is required - `(RTLM7i2)` Otherwise, call `addParentReference(parent, key)` per [RTLO4g](#RTLO4g) on the `LiveObject` in the local `ObjectsPool` with `objectId` equal to `MapSet.value.objectId` (guaranteed to exist per [RTLM7g](#RTLM7g)), passing this `LiveMap` as `parent` and the operation's key as `key` - `(RTLM7f)` Return a `LiveMapUpdate` object with a `LiveMapUpdate.update` map containing the key used in this operation set to `updated`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` @@ -732,7 +732,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM8g)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM8a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: - `(RTLM8a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLM8a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM8a2](#RTLM8a2) is applied, the parent reference recorded on existing `ObjectsMapEntry` MUST be removed: + - `(RTLM8a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM8a2](#RTLM8a2) is applied, the parent reference recorded on existing `ObjectsMapEntry` must be removed: - `(RTLM8a3a)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` - `(RTLM8a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the operation's key as `key` - `(RTLM8a2)` Otherwise, apply the operation to the existing entry: @@ -758,7 +758,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM24d)` Set the private `clearTimeserial` to the provided `serial` - `(RTLM24e)` For each `ObjectsMapEntry` in the internal `data`: - `(RTLM24e1)` If `ObjectsMapEntry.timeserial` is null or omitted, or the `serial` is lexicographically greater than `ObjectsMapEntry.timeserial`: - - `(RTLM24e1c)` If the current `ObjectsMapEntry` is of type `LiveObject`, the parent reference recorded on existing `ObjectsMapEntry` MUST be removed: + - `(RTLM24e1c)` If the current `ObjectsMapEntry` is of type `LiveObject`, the parent reference recorded on existing `ObjectsMapEntry` must be removed: - `(RTLM24e1c1)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` - `(RTLM24e1c2)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the iterated entry key as `key` - `(RTLM24e1a)` Remove the entry from the internal `data` map. The entry is not retained as a tombstone. From 9490e949a42649a69e492d622cc7855c75a270e1 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:07:16 -0300 Subject: [PATCH 31/43] Add argument types to addParentReference/removeParentReference IDL The IDL entries imported from PR #480 declared these two methods without argument types. Annotate them as (LiveMap parent, String key), matching the conventional style used for multi-arg methods elsewhere in the IDL and the parent/key descriptions in the RTLO4g/RTLO4h prose. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index bd5850836..28ef8002d 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -1133,8 +1133,8 @@ Types and their properties/methods are public and exposed to users by default. A parentReferences: Dict> // RTLO3f canApplyOperation(ObjectMessage) -> Boolean // RTLO4a tombstone(ObjectMessage) -> LiveObjectUpdate // RTLO4e - addParentReference(parent, key) // RTLO4g - removeParentReference(parent, key) // RTLO4h + addParentReference(LiveMap parent, String key) // RTLO4g + removeParentReference(LiveMap parent, String key) // RTLO4h subscribe((LiveObjectUpdate) ->) -> Subscription // RTLO4b interface LiveObjectUpdate: // RTLO4b4, internal From 4460c40be2f300518270e6408e36b546b869b613 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:08:22 -0300 Subject: [PATCH 32/43] Add getFullPaths to the LiveObject IDL The RTLO4f getFullPaths clause was added to the prose spec but missed from the IDL. Add it as `getFullPaths() -> String[][]`, positioned between tombstone (RTLO4e) and addParentReference (RTLO4g) to preserve clause-letter ordering. The return type reflects RTLO4f, which describes the result as a list of distinct paths, each being an ordered sequence of string keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 28ef8002d..b45d87e22 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -1133,6 +1133,7 @@ Types and their properties/methods are public and exposed to users by default. A parentReferences: Dict> // RTLO3f canApplyOperation(ObjectMessage) -> Boolean // RTLO4a tombstone(ObjectMessage) -> LiveObjectUpdate // RTLO4e + getFullPaths() -> String[][] // RTLO4f addParentReference(LiveMap parent, String key) // RTLO4g removeParentReference(LiveMap parent, String key) // RTLO4h subscribe((LiveObjectUpdate) ->) -> Subscription // RTLO4b From 6b3a8b18896648038a480d58dcfcbb7171a3b7d5 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:31:34 -0300 Subject: [PATCH 33/43] Tighten parent-presence wording in RTLO4g/RTLO4h Make the objectId-keyed lookup convention explicit at the point of use, rather than relying on the reader to infer it from RTLO3f. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index b45d87e22..fd9826e89 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -367,12 +367,12 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4a5)` If the `siteSerial` for this `LiveObject` is null or an empty string, return true - `(RTLO4a6)` If the `siteSerial` for this `LiveObject` is not an empty string, return true if `ObjectMessage.serial` is greater than `siteSerial` when compared lexicographically - `(RTLO4g)` internal `addParentReference(parent, key)` method - records that the `LiveMap` `parent` references this `LiveObject` at `key` - - `(RTLO4g1)` If `parent` is already present in `parentReferences`, `key` must be added to the existing set associated with `parent` - - `(RTLO4g2)` Otherwise, a new entry must be inserted into `parentReferences` for `parent` with a set containing only `key` + - `(RTLO4g1)` If `parentReferences` already contains an entry whose key is `parent.objectId`, add `key` to that entry's set + - `(RTLO4g2)` Otherwise, insert into `parentReferences` a new entry whose key is `parent.objectId` and whose value is a set containing only `key` - `(RTLO4h)` internal `removeParentReference(parent, key)` method - removes the recorded reference from `parent` at `key` - - `(RTLO4h1)` If `parent` is not present in `parentReferences`, the call must be a no-op - - `(RTLO4h2)` Otherwise, `key` must be removed from the set associated with `parent` - - `(RTLO4h3)` If, as a result of [RTLO4h2](#RTLO4h2), the set associated with `parent` is empty, the `parent` entry must be removed from `parentReferences` + - `(RTLO4h1)` If `parentReferences` does not contain an entry whose key is `parent.objectId`, do nothing + - `(RTLO4h2)` Otherwise, remove `key` from that entry's set + - `(RTLO4h3)` If, as a result of [RTLO4h2](#RTLO4h2), that entry's set is empty, remove the entry from `parentReferences` - `(RTLO4e)` protected `tombstone` - a convenience method used to tombstone this `LiveObject`. The realtime system reserves the right to tombstone an object (i.e. mark it for deletion from the objects pool) by publishing an `OBJECT_DELETE` operation at any time if the object is orphaned (not a descendant of the root object) or remains uninitialized (no `*_CREATE` operation has been received) for an extended period. Only the realtime system may publish an `OBJECT_DELETE` operation; clients must never send it. This method describes the steps the client library must take when it needs to tombstone an object locally. Eventually, tombstoned objects will be garbage collected following the procedure described in [RTO10](#RTO10) - `(RTLO4e1)` Expects the following arguments: - `(RTLO4e1a)` `ObjectMessage` From edd06626615942d7f335d0ea512068c293c3b0cb Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:50:14 -0300 Subject: [PATCH 34/43] Simplify RTO5c10 Drop the explicit ordering language (it's implied by the surrounding RTO5c sequence), merge the entries-iteration and addParentReference sub-clauses into one, and defer to LiveMap#entries to determine when a value is a LiveObject. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index fd9826e89..bee9fb0fa 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -174,10 +174,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO5c1b1c)` This clause has been deleted (redundant to [RTO5f3](#RTO5f3)). - `(RTO5c2)` Remove any objects from the internal `ObjectsPool` for which `objectId`s were not received during the sync sequence - `(RTO5c2a)` The object with ID `root` must not be removed from `ObjectsPool`, as per [RTO3b](#RTO3b) - - `(RTO5c10)` After re-establishing the `ObjectsPool` per [RTO5c1](#RTO5c1) and [RTO5c2](#RTO5c2), the client must rebuild every `parentReferences` map ([RTLO3f](#RTLO3f)). Specifically: - - `(RTO5c10a)` For each `LiveObject` in the internal `ObjectsPool` ([RTO3](#RTO3)), reset its `parentReferences` to an empty map as defined in [RTLO3f2](#RTLO3f2) - - `(RTO5c10b)` After [RTO5c10a](#RTO5c10a) has completed, for each `LiveMap` in the internal `ObjectsPool`, iterate its `LiveMap#entries` as per [RTLM11](#RTLM11) - - `(RTO5c10b1)` For each iterated entry whose value type is `LiveObject`, call `addParentReference(parent, key)` on the `LiveObject` (per [RTLO4g](#RTLO4g)), passing the iterated `LiveMap` as `parent` and the iterated entry key as `key` + - `(RTO5c10)` Rebuild every `parentReferences` map ([RTLO3f](#RTLO3f)): + - `(RTO5c10a)` For each `LiveObject` in the internal `ObjectsPool`, reset its `parentReferences` to the initial value defined in [RTLO3f2](#RTLO3f2) + - `(RTO5c10b)` For each `LiveMap` in the internal `ObjectsPool`, iterate its `LiveMap#entries` ([RTLM11](#RTLM11)); for each entry whose value is a `LiveObject`, call `addParentReference(parent, key)` on that `LiveObject` per [RTLO4g](#RTLO4g), passing the `LiveMap` as `parent` and the entry's key as `key` - `(RTO5c7)` For each previously existing object that was updated as a result of [RTO5c1a](#RTO5c1a), emit the corresponding stored `LiveObjectUpdate` object from [RTO5c1a2](#RTO5c1a2) - `(RTO5c6)` `ObjectMessages` stored in the `bufferedObjectOperations` list are applied as described in [RTO9](#RTO9), passing `source` as `CHANNEL` - `(RTO5c3)` Clear any stored sync sequence identifiers and cursor values From fb8be907bad75ac833dab1dbf466835aea2e6ce7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 17:02:00 -0300 Subject: [PATCH 35/43] Rework RTLO4e9 tombstone children walk Fix the key argument (Sachin's version passed entry.value, not the entry's key), align terminology with ObjectsMapEntry naming used elsewhere in the file, flatten the nesting, and re-position relative to RTLO4e4 by referencing the previous value of LiveMap.data instead of imposing a "before RTLO4e4" ordering. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index bee9fb0fa..329de239e 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -380,11 +380,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e3a)` This clause has been replaced by [RTLO6a](#RTLO6a) - `(RTLO4e3b)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLO4e3b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1) - - `(RTLO4e9)` If the current `LiveObject` is of type `LiveMap`, then before [RTLO4e4](#RTLO4e4) is applied, do following: - - `(RTLO4e9a)` For each iterated `entry` in current `LiveMap`'s private `data`: - - `(RTLO4e9a1)` If `entry.value.data` have `objectId` as a field, retrieve corresponding child `LiveObject` from `ObjectsPool` using given `objectId` - - `(RTLO4e9a2)` If child `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing current `LiveMap` as `parent` and the iterated `entry.value` as `key` - `(RTLO4e4)` Set the `data` attribute of the `LiveObject` to the value described in [RTLC4b](#RTLC4b) or [RTLM4c](#RTLM4c), depending on the object type + - `(RTLO4e9)` If the `LiveObject` is a `LiveMap`, then for each `ObjectsMapEntry` in the previous value of `LiveMap.data` (that is, the value before resetting it in [`RTLO4e4`](#RTLO4e4)): + - `(RTLO4e9a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` + - `(RTLO4e9b)` If the [`RTLO4e9a`](#RTLO4e9a) fetch returned an object, then call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing the `LiveMap` as `parent` and the iterated entry's key as `key` - `(RTLO4e5)` Compute a `LiveObjectUpdate` representing the data change resulting from this `LiveObject` being tombstoned, by calculating the diff between the `data` value from before [RTLO4e4](#RTLO4e4) was applied (as `previousData`) and the current `data` value (as `newData`), per [RTLC14](#RTLC14) or [RTLM22](#RTLM22), depending on the object type - `(RTLO4e6)` Set `LiveObjectUpdate.tombstone` to `true` on the object computed in [RTLO4e5](#RTLO4e5) - `(RTLO4e7)` Set `LiveObjectUpdate.objectMessage` on the object computed in [RTLO4e5](#RTLO4e5) to the `ObjectMessage` argument From 3860085fb7f3f760e20faade07976268c9612ef7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 17:09:57 -0300 Subject: [PATCH 36/43] Tidy RTLM7 parent-reference clauses Fold RTLM7i's parent-reference recording into RTLM7g as RTLM7g2, removing the duplicated MapSet.value.objectId presence check. Also replace "the operation's key" with "the specified key" in RTLM7a3b, RTLM7g2 and RTLM8a3b, matching the wording used by the surrounding RTLM7a/b/b4 and RTLM8a/b/b1 clauses. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 329de239e..30a41944e 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -699,7 +699,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM7a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM7a2](#RTLM7a2e) is applied, the parent reference recorded on existing `ObjectsMapEntry` must be removed: - `(RTLM7a3a)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` - - `(RTLM7a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the operation's key as `key` + - `(RTLM7a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM7a2)` Otherwise, apply the operation to the existing entry: - `(RTLM7a2a)` This clause has been replaced by [RTLM7a2e](#RTLM7a2e) as of specification version 6.0.0. - `(RTLM7a2e)` Set `ObjectsMapEntry.data` to the `MapSet.value` @@ -715,9 +715,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7c1)` This clause has been replaced by [RTLM7g1](#RTLM7g1) as of specification version 6.0.0. - `(RTLM7g)` If `MapSet.value.objectId` is non-empty: - `(RTLM7g1)` Create a new `LiveObject` for this `objectId` in the internal `ObjectsPool` per [RTO6](#RTO6) - - `(RTLM7i)` A parent reference must be recorded on the `LiveObject` newly referenced by this entry (if any): - - `(RTLM7i1)` If `MapSet.value.objectId` is not present, no action is required - - `(RTLM7i2)` Otherwise, call `addParentReference(parent, key)` per [RTLO4g](#RTLO4g) on the `LiveObject` in the local `ObjectsPool` with `objectId` equal to `MapSet.value.objectId` (guaranteed to exist per [RTLM7g](#RTLM7g)), passing this `LiveMap` as `parent` and the operation's key as `key` + - `(RTLM7g2)` Call `addParentReference(parent, key)` per [RTLO4g](#RTLO4g) on the `LiveObject` from [RTLM7g1](#RTLM7g1), passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM7f)` Return a `LiveMapUpdate` object with a `LiveMapUpdate.update` map containing the key used in this operation set to `updated`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` - `(RTLM8)` A `MAP_REMOVE` operation for a key can be applied to a `LiveMap` in the following way: - `(RTLM8c)` Expects the following arguments: @@ -732,7 +730,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM8a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM8a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM8a2](#RTLM8a2) is applied, the parent reference recorded on existing `ObjectsMapEntry` must be removed: - `(RTLM8a3a)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` - - `(RTLM8a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the operation's key as `key` + - `(RTLM8a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM8a2)` Otherwise, apply the operation to the existing entry: - `(RTLM8a2a)` Set `ObjectsMapEntry.data` to undefined/null - `(RTLM8a2b)` Set `ObjectsMapEntry.timeserial` to the provided `serial` From ac47bd77805a33369aea1dc4003316af8885a9fa Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 17:21:20 -0300 Subject: [PATCH 37/43] Tighten parent-ref cleanup clauses to a single parallel form RTLM7a3, RTLM8a3, RTLM24e1c and RTLO4e9 now all share the same "Before [target] is applied: { fetch from ObjectsPool; if found call removeParentReference }" shape, dropping the imprecise "ObjectsMapEntry is of type LiveObject" / "parent reference recorded on existing ObjectsMapEntry" wording. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 30a41944e..4d9459808 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -380,10 +380,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e3a)` This clause has been replaced by [RTLO6a](#RTLO6a) - `(RTLO4e3b)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLO4e3b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1) - - `(RTLO4e4)` Set the `data` attribute of the `LiveObject` to the value described in [RTLC4b](#RTLC4b) or [RTLM4c](#RTLM4c), depending on the object type - - `(RTLO4e9)` If the `LiveObject` is a `LiveMap`, then for each `ObjectsMapEntry` in the previous value of `LiveMap.data` (that is, the value before resetting it in [`RTLO4e4`](#RTLO4e4)): + - `(RTLO4e9)` If the `LiveObject` is a `LiveMap`, then before [RTLO4e4](#RTLO4e4) is applied, for each `ObjectsMapEntry` in `LiveMap.data`: - `(RTLO4e9a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` - - `(RTLO4e9b)` If the [`RTLO4e9a`](#RTLO4e9a) fetch returned an object, then call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing the `LiveMap` as `parent` and the iterated entry's key as `key` + - `(RTLO4e9b)` If the [`RTLO4e9a`](#RTLO4e9a) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the iterated entry's key as `key` + - `(RTLO4e4)` Set the `data` attribute of the `LiveObject` to the value described in [RTLC4b](#RTLC4b) or [RTLM4c](#RTLM4c), depending on the object type - `(RTLO4e5)` Compute a `LiveObjectUpdate` representing the data change resulting from this `LiveObject` being tombstoned, by calculating the diff between the `data` value from before [RTLO4e4](#RTLO4e4) was applied (as `previousData`) and the current `data` value (as `newData`), per [RTLC14](#RTLC14) or [RTLM22](#RTLM22), depending on the object type - `(RTLO4e6)` Set `LiveObjectUpdate.tombstone` to `true` on the object computed in [RTLO4e5](#RTLO4e5) - `(RTLO4e7)` Set `LiveObjectUpdate.objectMessage` on the object computed in [RTLO4e5](#RTLO4e5) to the `ObjectMessage` argument @@ -697,9 +697,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7h)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM7a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: - `(RTLM7a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLM7a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM7a2](#RTLM7a2e) is applied, the parent reference recorded on existing `ObjectsMapEntry` must be removed: - - `(RTLM7a3a)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` - - `(RTLM7a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the specified key as `key` + - `(RTLM7a3)` Before [RTLM7a2](#RTLM7a2e) is applied: + - `(RTLM7a3a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` + - `(RTLM7a3b)` If the [`RTLM7a3a`](#RTLM7a3a) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM7a2)` Otherwise, apply the operation to the existing entry: - `(RTLM7a2a)` This clause has been replaced by [RTLM7a2e](#RTLM7a2e) as of specification version 6.0.0. - `(RTLM7a2e)` Set `ObjectsMapEntry.data` to the `MapSet.value` @@ -728,9 +728,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM8g)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM8a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: - `(RTLM8a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLM8a3)` If the current `ObjectsMapEntry` is of type `LiveObject`, before [RTLM8a2](#RTLM8a2) is applied, the parent reference recorded on existing `ObjectsMapEntry` must be removed: - - `(RTLM8a3a)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` - - `(RTLM8a3b)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the specified key as `key` + - `(RTLM8a3)` Before [RTLM8a2](#RTLM8a2) is applied: + - `(RTLM8a3a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` + - `(RTLM8a3b)` If the [`RTLM8a3a`](#RTLM8a3a) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM8a2)` Otherwise, apply the operation to the existing entry: - `(RTLM8a2a)` Set `ObjectsMapEntry.data` to undefined/null - `(RTLM8a2b)` Set `ObjectsMapEntry.timeserial` to the provided `serial` @@ -754,9 +754,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM24d)` Set the private `clearTimeserial` to the provided `serial` - `(RTLM24e)` For each `ObjectsMapEntry` in the internal `data`: - `(RTLM24e1)` If `ObjectsMapEntry.timeserial` is null or omitted, or the `serial` is lexicographically greater than `ObjectsMapEntry.timeserial`: - - `(RTLM24e1c)` If the current `ObjectsMapEntry` is of type `LiveObject`, the parent reference recorded on existing `ObjectsMapEntry` must be removed: - - `(RTLM24e1c1)` To check `ObjectsMapEntry` is of type `LiveObject`, validate `ObjectsMapEntry.data` has a `objectId` field, retrieve corresponding `LiveObject` from `ObjectsPool` using given `objectId` - - `(RTLM24e1c2)` If `LiveObject` exists, call its `removeParentReference(parent, key)` method per [RTLO4h](#RTLO4h), passing this `LiveMap` as `parent` and the iterated entry key as `key` + - `(RTLM24e1c)` Before [RTLM24e1a](#RTLM24e1a) is applied: + - `(RTLM24e1c1)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` + - `(RTLM24e1c2)` If the [`RTLM24e1c1`](#RTLM24e1c1) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the iterated entry's key as `key` - `(RTLM24e1a)` Remove the entry from the internal `data` map. The entry is not retained as a tombstone. - `(RTLM24e1b)` Record the key for the `LiveMapUpdate` as `removed` - `(RTLM24f)` Return a `LiveMapUpdate` object with `LiveMapUpdate.update` containing each key recorded in [RTLM24e1b](#RTLM24e1b) set to `removed`, and `LiveMapUpdate.objectMessage` set to the provided `ObjectMessage` From 9d16a5613ee7b8e71efb0fc6e8e891b34872c852 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 17:29:12 -0300 Subject: [PATCH 38/43] Restructure parent-ref cleanup clauses around the data modification Nest RTLM7a3 and RTLM8a3 inside RTLM7a2 / RTLM8a2 (the "Otherwise, apply" branches) so their "Otherwise" pair with the noop check isn't obscured, and reword all four parent clauses (RTLM7a3, RTLM8a3, RTLM24e1c, RTLO4e9) to name the data modification each one precedes (set / cleared / removed / reset). Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 4d9459808..4d4c61025 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -380,7 +380,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e3a)` This clause has been replaced by [RTLO6a](#RTLO6a) - `(RTLO4e3b)` This clause has been replaced by [RTLO6b](#RTLO6b) - `(RTLO4e3b1)` This clause has been replaced by [RTLO6b1](#RTLO6b1) - - `(RTLO4e9)` If the `LiveObject` is a `LiveMap`, then before [RTLO4e4](#RTLO4e4) is applied, for each `ObjectsMapEntry` in `LiveMap.data`: + - `(RTLO4e9)` If the `LiveObject` is a `LiveMap`, then before `LiveMap.data` is reset per [RTLO4e4](#RTLO4e4), for each `ObjectsMapEntry` in `LiveMap.data`: - `(RTLO4e9a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` - `(RTLO4e9b)` If the [`RTLO4e9a`](#RTLO4e9a) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the iterated entry's key as `key` - `(RTLO4e4)` Set the `data` attribute of the `LiveObject` to the value described in [RTLC4b](#RTLC4b) or [RTLM4c](#RTLM4c), depending on the object type @@ -697,10 +697,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM7h)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM7a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: - `(RTLM7a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLM7a3)` Before [RTLM7a2](#RTLM7a2e) is applied: - - `(RTLM7a3a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` - - `(RTLM7a3b)` If the [`RTLM7a3a`](#RTLM7a3a) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM7a2)` Otherwise, apply the operation to the existing entry: + - `(RTLM7a3)` Before `ObjectsMapEntry.data` is set per [RTLM7a2e](#RTLM7a2e): + - `(RTLM7a3a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` + - `(RTLM7a3b)` If the [`RTLM7a3a`](#RTLM7a3a) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM7a2a)` This clause has been replaced by [RTLM7a2e](#RTLM7a2e) as of specification version 6.0.0. - `(RTLM7a2e)` Set `ObjectsMapEntry.data` to the `MapSet.value` - `(RTLM7a2b)` Set `ObjectsMapEntry.timeserial` to the provided `serial` @@ -728,10 +728,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM8g)` If the private `clearTimeserial` is non-null, and the provided `serial` is null or the `clearTimeserial` is lexicographically greater than or equal to `serial`, discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - `(RTLM8a)` If an `ObjectsMapEntry` exists in the private `data` for the specified key: - `(RTLM8a1)` If the operation cannot be applied to the existing entry as per [RTLM9](#RTLM9), discard the operation without taking any action. Return a `LiveMapUpdate` object with `LiveMapUpdate.noop` set to `true`, indicating that no update was made to the object - - `(RTLM8a3)` Before [RTLM8a2](#RTLM8a2) is applied: - - `(RTLM8a3a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` - - `(RTLM8a3b)` If the [`RTLM8a3a`](#RTLM8a3a) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM8a2)` Otherwise, apply the operation to the existing entry: + - `(RTLM8a3)` Before `ObjectsMapEntry.data` is cleared per [RTLM8a2a](#RTLM8a2a): + - `(RTLM8a3a)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` + - `(RTLM8a3b)` If the [`RTLM8a3a`](#RTLM8a3a) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the specified key as `key` - `(RTLM8a2a)` Set `ObjectsMapEntry.data` to undefined/null - `(RTLM8a2b)` Set `ObjectsMapEntry.timeserial` to the provided `serial` - `(RTLM8a2c)` Set `ObjectsMapEntry.tombstone` to `true` @@ -754,7 +754,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM24d)` Set the private `clearTimeserial` to the provided `serial` - `(RTLM24e)` For each `ObjectsMapEntry` in the internal `data`: - `(RTLM24e1)` If `ObjectsMapEntry.timeserial` is null or omitted, or the `serial` is lexicographically greater than `ObjectsMapEntry.timeserial`: - - `(RTLM24e1c)` Before [RTLM24e1a](#RTLM24e1a) is applied: + - `(RTLM24e1c)` Before the `ObjectsMapEntry` is removed per [RTLM24e1a](#RTLM24e1a): - `(RTLM24e1c1)` If `ObjectsMapEntry.data.objectId` is populated, fetch the object with this `objectId` from the `ObjectsPool` - `(RTLM24e1c2)` If the [`RTLM24e1c1`](#RTLM24e1c1) fetch returned an object, call its [`RTLO4h`](#RTLO4h) `removeParentReference(parent, key)` method, passing this `LiveMap` as `parent` and the iterated entry's key as `key` - `(RTLM24e1a)` Remove the entry from the internal `data` map. The entry is not retained as a tombstone. From fd222d2d2974df0388489df4d62dbc84586cf9e4 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 18:11:37 -0300 Subject: [PATCH 39/43] Rework RTLO4f around an explicit graph-theoretic definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the seven-clause MUST-style spec with four: define a directed graph G over parentReferences, return the *key-paths* corresponding to G's simple paths from root to this LiveObject. The new term *key-path* (matching PathObject's "path" concept) is used here to distinguish from the graph-theoretical "simple path". Edge cases (root, orphan, multi-key, multi-ancestor, cycles) fall out of the definition. There's a tension here: the most universal contract would just say "returns the key-paths from root to this LiveObject" and leave the mechanism to SDK implementers. But any SDK implementing `getFullPaths` will probably want a `parentReferences`-equivalent data structure, and keeping that structure consistent across the many places where `LiveMap.data` is mutated (`MAP_SET`, `MAP_REMOVE`, `MAP_CLEAR`, tombstone, sync rebuild) is the part SDKs are likely to get wrong. The prescriptive `parentReferences`-based formulation pays for itself by making those bookkeeping responsibilities explicit at each mutation site. If we hadn't already specified `parentReferences` and its maintenance, we might not have bothered — but we have, so let's use it. Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 4d4c61025..dad8552ac 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -388,14 +388,11 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO4e6)` Set `LiveObjectUpdate.tombstone` to `true` on the object computed in [RTLO4e5](#RTLO4e5) - `(RTLO4e7)` Set `LiveObjectUpdate.objectMessage` on the object computed in [RTLO4e5](#RTLO4e5) to the `ObjectMessage` argument - `(RTLO4e8)` Return the `LiveObjectUpdate` object computed in [RTLO4e5](#RTLO4e5) - - `(RTLO4f)` internal `getFullPaths` function - returns the list of distinct paths from the root `LiveMap` (objectId `root`) to this `LiveObject`, computed by traversing `parentReferences` upward. Each returned path is an ordered sequence of keys from `root` to this `LiveObject`. - - `(RTLO4f1)` `getFullPaths` MUST be implemented as an enumeration of all *simple paths* from this `LiveObject` to the root `LiveMap` over the inverse of the `parentReferences` graph (i.e. walking child → parent). A *simple path* is a path along which no `LiveObject` appears more than once. This is the standard graph problem, typically solved by a depth-first traversal with path-local backtracking equivalent to NetworkX's `all_simple_paths`. Implementation should choose iterative DFS with explicit stack (easier to read and debugging). - - `(RTLO4f2)` If this `LiveObject` is the root `LiveMap` (objectId `root`), the returned list MUST contain exactly one path, and that path MUST be empty (zero key segments). This makes the root reachable from itself via the empty key sequence - - `(RTLO4f3)` If this `LiveObject` is not the root `LiveMap` and has no entries in its `parentReferences` at the time of the call (e.g. orphaned, or not yet reachable from root), the returned list MUST be empty - - `(RTLO4f4)` While traversing paths, suppress cyclic paths whenever a sibling branch had already revisited the same node. Reference behaviour on cyclic graphs is given by NetworkX's `all_simple_paths`, which implementations MAY consult for worked examples - - `(RTLO4f5)` When a single parent `LiveMap` references this `LiveObject` at multiple keys, the returned list MUST contain one distinct path per such key, each ending at the corresponding key - - `(RTLO4f6)` When this `LiveObject` is reachable via multiple distinct ancestor paths (either because it has multiple parents in `parentReferences`, or because any ancestor on the way to root itself has multiple paths to root), the returned list MUST contain one path per distinct ancestor path - - `(RTLO4f7)` The order of paths in the returned list is not mandatory. Implementations MAY return paths in any order; callers requiring a stable order MUST sort the result themselves + - `(RTLO4f)` internal `getFullPaths` function - returns the list of all key-paths from the root `LiveMap` (objectId `root`) to this `LiveObject`. A *key-path* is a list of zero or more keys (the same concept as "path" elsewhere in this spec, e.g. on `PathObject`); we use the term key-path in this clause specifically to distinguish it from the graph-theoretical "simple path" used in [RTLO4f2](#RTLO4f2) + - `(RTLO4f1)` Which key-paths are returned is determined via a directed graph G defined as follows. The nodes of G are the `LiveObject`s in the `ObjectsPool`. For each `(parent, key)` pair recorded in `child`'s `parentReferences` ([RTLO3f](#RTLO3f)), G has a directed edge from `parent` to `child` labelled `key` + - `(RTLO4f2)` A *simple path* in G is a sequence of edges visiting each node at most once. Each such path in G from `root` to this `LiveObject` contributes one key-path to the returned list: the list of its edge labels. The empty simple path (which exists only when this `LiveObject` is itself `root`) contributes the empty key-path `[]` + - `(RTLO4f3)` Each such key-path appears in the returned list exactly once. The order is unspecified + - `(RTLO4f4)` (non-normative) A typical approach is iterative DFS with an explicit stack: walk upward from this `LiveObject` toward `root` via `parentReferences`, collecting keys along the way and skipping branches that would revisit a node - `(RTLO5)` An `OBJECT_DELETE` operation can be applied to a `LiveObject` in the following way: - `(RTLO5a)` Expects the following arguments: - `(RTLO5a1)` `ObjectMessage` From 052a6a8bbe785d01d9ae372ee6e33a8c183c7529 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 22:06:30 -0300 Subject: [PATCH 40/43] Centralise PathObject/Instance access checks in RTO25/RTO26 Move the OBJECT_SUBSCRIBE mode + channel-state check (access API preconditions) and the OBJECT_PUBLISH mode + channel-state + echoMessages check (write API preconditions) out of the LiveMap/LiveCounter/LiveObject public methods and into two new common clauses (RTO25 and RTO26). Each PathObject and Instance public method that accesses or mutates data now references the applicable preconditions and renumbers its sub-clauses so the check sits in a logical position (after Expects, before any data work). External cross-references to the renumbered sub-clauses, including the IDL section, are updated. Two motivations: 1. Previously the spec placed these checks on LiveMap/LiveCounter, which delegating PathObject/Instance methods triggered only after path resolution and type checks. A call against a stale or detached channel could then yield a "wrong type" result (empty array etc.) instead of a state error. ably-js already moved the checks to the public entry points for this reason (commit a7462b14, "Handle channel configuration checks on PathObject/Instance level instead of LiveMap/LiveCounter"). 2. With the checks lifted out, the underlying LiveMap/LiveCounter methods become non-throwing for channel-state reasons. This matters for internal callers that invoke them in a non-throwing context, e.g. RTO5c10b iterating LiveMap#entries during the post-sync parentReferences rebuild. See [1]. The displaced LiveMap/LiveCounter/LiveObject sub-clauses are kept as "replaced by RTO25/RTO26" markers rather than deleted. [1] https://github.com/ably/specification/pull/477#discussion_r3281612167 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 265 ++++++++++++++++------------- 1 file changed, 149 insertions(+), 116 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index dad8552ac..1de79f00a 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -127,6 +127,13 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO2b)` Otherwise, a best-effort attempt is made, and the channel mode is checked against the set of channel modes requested by the user per [TB2d](../features#TB2d) : - `(RTO2b1)` If the channel mode is in the set, the operation is allowed - `(RTO2b2)` If the channel mode is missing, unless otherwise specified by the operation, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40024, indicating that the operation cannot be performed without the required channel mode +- `(RTO25)` Certain object operations may require the *access API preconditions* to be satisfied in order to be performed. If the access API preconditions are required by an operation, then before doing anything else: + - `(RTO25a)` Require the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) + - `(RTO25b)` If the channel is in the `DETACHED` or `FAILED` state, throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 +- `(RTO26)` Certain object operations may require the *write API preconditions* to be satisfied in order to be performed. If the write API preconditions are required by an operation, then before doing anything else: + - `(RTO26a)` Require the `OBJECT_PUBLISH` channel mode to be granted per [RTO2](#RTO2) + - `(RTO26b)` If the channel is in the `DETACHED`, `FAILED`, or `SUSPENDED` state, throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 + - `(RTO26c)` If [`echoMessages`](../features#TO3h) client option is `false`, throw an `ErrorInfo` error with `statusCode` 400 and `code` 40000, indicating that `echoMessages` must be enabled for this operation - `(RTO3)` An internal `ObjectsPool` should be used to maintain the list of objects present on a channel - `(RTO3a)` `ObjectsPool` is a `Dict` - a map of `LiveObject`s keyed by [`objectId`](../features#OST2a) string - `(RTO3b)` It must always contain a `LiveMap` object with id `root` @@ -331,8 +338,8 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO3f2)` Set to an empty map when the `LiveObject` is initialized - `(RTLO4)` `LiveObject` methods: - `(RTLO4b)` `subscribe` - subscribes a user to data updates on this `LiveObject` instance - - `(RTLO4b1)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - - `(RTLO4b2)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 + - `(RTLO4b1)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers + - `(RTLO4b2)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers - `(RTLO4b3)` A user may provide a listener to subscribe to data updates on this `LiveObject` instance - `(RTLO4b4)` An update to `LiveObject` data is communicated by internally emitting a `LiveObjectUpdate` object for this `LiveObject`, or in any other platform-appropriate manner: - `(RTLO4b4a)` `LiveObjectUpdate.update` contains the specific information about what was changed on the object. The exact type depends on the object type @@ -416,15 +423,15 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLC11b)` `LiveCounterUpdate.update` has the following properties: - `(RTLC11b1)` `amount` number - the value by which the counter was incremented or decremented - `(RTLC5)` `LiveCounter#value` function: - - `(RTLC5a)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - - `(RTLC5b)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 + - `(RTLC5a)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers + - `(RTLC5b)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers - `(RTLC5c)` Returns the current `data` value - `(RTLC12)` `LiveCounter#increment` function: - `(RTLC12a)` Expects the following arguments: - `(RTLC12a1)` `amount` `Number` - the amount by which to increment the counter value - - `(RTLC12b)` Requires the `OBJECT_PUBLISH` channel mode to be granted per [RTO2](#RTO2) - - `(RTLC12c)` If the channel is in the `DETACHED`, `FAILED` or `SUSPENDED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - - `(RTLC12d)` If [`echoMessages`](../features#TO3h) client option is `false`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40000, indicating that `echoMessages` must be enabled for this operation + - `(RTLC12b)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers + - `(RTLC12c)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers + - `(RTLC12d)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers - `(RTLC12e)` Creates an `ObjectMessage` for a `COUNTER_INC` action in the following way: - `(RTLC12e1)` If `amount` is null, not of type `Number`, not a finite number, or omitted, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003, indicating that `amount` must be a valid number - `(RTLC12e2)` Set `ObjectMessage.operation.action` to `ObjectOperationAction.COUNTER_INC` @@ -530,8 +537,8 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM18b)` `LiveMapUpdate.update` is of type `Dict` - a map of `LiveMap` keys that were either updated or removed, with the corresponding value indicating the type of change for each key - `(RTLM5)` `LiveMap#get` function: - `(RTLM5a)` Accepts a key of type String - - `(RTLM5b)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - - `(RTLM5c)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 + - `(RTLM5b)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers + - `(RTLM5c)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers - `(RTLM5e)` If `LiveMap.isTombstone` is `true`, return undefined/null - `(RTLM5d)` Returns the value from the current `data` at the specified key, as follows: - `(RTLM5d1)` If no `ObjectsMapEntry` exists at the key, return undefined/null @@ -549,13 +556,13 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM5d2g)` Otherwise, return undefined/null - `(RTLM10)` `LiveMap#size`: - `(RTLM10a)` A method or property, depending on what is more idiomatic for the platform to use for a Map/Dictionary interface. For example, in JavaScript, this is a property similar to `Map.size` for the native `Map` class - - `(RTLM10b)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - - `(RTLM10c)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 + - `(RTLM10b)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers + - `(RTLM10c)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers - `(RTLM10d)` Returns the number of non-tombstoned entries (per [RTLM14](#RTLM14)) in the internal `data` map - `(RTLM11)` `LiveMap#entries`: - `(RTLM11a)` A method or property, depending on what is more idiomatic for the platform to use for a Map/Dictionary interface. For example, in JavaScript, this is a method similar to `Map.entries()` for the native `Map` class - - `(RTLM11b)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - - `(RTLM11c)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 + - `(RTLM11b)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers + - `(RTLM11c)` This clause has been replaced by [RTO25](#RTO25); the access API preconditions are now checked by callers - `(RTLM11d)` Returns key-value pairs from the internal `data` map: - `(RTLM11d1)` Pairs with tombstoned entries (per [RTLM14](#RTLM14)) are not returned - `(RTLM11d3)` `ObjectsMapEntry` values are mapped to user-facing values following the same procedure as in [RTLM5d2](#RTLM5d2) @@ -572,9 +579,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM20a1)` `key` `String` - the key to set the value for - `(RTLM20a2)` This clause has been replaced by [RTLM20a3](#RTLM20a3). - `(RTLM20a3)` `value` `Boolean | Binary | Number | String | JsonArray | JsonObject | LiveCounterValueType | LiveMapValueType` - the value to assign to the key - - `(RTLM20b)` Requires the `OBJECT_PUBLISH` channel mode to be granted per [RTO2](#RTO2) - - `(RTLM20c)` If the channel is in the `DETACHED`, `FAILED` or `SUSPENDED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - - `(RTLM20d)` If [`echoMessages`](../features#TO3h) client option is `false`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40000, indicating that `echoMessages` must be enabled for this operation + - `(RTLM20b)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers + - `(RTLM20c)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers + - `(RTLM20d)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers - `(RTLM20e)` Creates an `ObjectMessage` for a `MAP_SET` action in the following way: - `(RTLM20e1)` Validates the provided `key` and `value` in a similar way as described in [RTLMV4b](#RTLMV4b) and [RTLMV4c](#RTLMV4c) - `(RTLM20e2)` Set `ObjectMessage.operation.action` to `ObjectOperationAction.MAP_SET` @@ -606,9 +613,9 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM21)` `LiveMap#remove` function: - `(RTLM21a)` Expects the following arguments: - `(RTLM21a1)` `key` `String` - the key to remove the value for - - `(RTLM21b)` Requires the `OBJECT_PUBLISH` channel mode to be granted per [RTO2](#RTO2) - - `(RTLM21c)` If the channel is in the `DETACHED`, `FAILED` or `SUSPENDED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - - `(RTLM21d)` If [`echoMessages`](../features#TO3h) client option is `false`, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40000, indicating that `echoMessages` must be enabled for this operation + - `(RTLM21b)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers + - `(RTLM21c)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers + - `(RTLM21d)` This clause has been replaced by [RTO26](#RTO26); the write API preconditions are now checked by callers - `(RTLM21e)` Creates an `ObjectMessage` for a `MAP_REMOVE` action in the following way: - `(RTLM21e1)` Validates the provided `key` in a similar way as described in [RTLMV4b](#RTLMV4b) - `(RTLM21e2)` Set `ObjectMessage.operation.action` to `ObjectOperationAction.MAP_REMOVE` @@ -895,85 +902,98 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO6c)` Returns a new `PathObject` with the same `root` and with the parsed segments appended to the current `path` segments - `(RTPO6d)` This is a convenience for chaining multiple `PathObject#get` calls. For example, `pathObject.at("a.b.c")` is equivalent to `pathObject.get("a").get("b").get("c")` - `(RTPO7)` `PathObject#value` function: - - `(RTPO7a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO7b)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#value` ([RTLC5](#RTLC5)) - - `(RTPO7c)` If the resolved value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns the value directly - - `(RTPO7d)` If the resolved value is a `LiveMap`, returns undefined/null - - `(RTPO7e)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) + - `(RTPO7a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO7b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO7c)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#value` ([RTLC5](#RTLC5)) + - `(RTPO7d)` If the resolved value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns the value directly + - `(RTPO7e)` If the resolved value is a `LiveMap`, returns undefined/null + - `(RTPO7f)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) - `(RTPO8)` `PathObject#instance` function: - - `(RTPO8a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO8b)` If the resolved value is a `LiveObject` (i.e. a `LiveMap` or `LiveCounter`), returns a new `Instance` ([RTINS1](#RTINS1)) wrapping that `LiveObject` - - `(RTPO8c)` If the resolved value is a primitive, returns undefined/null - - `(RTPO8d)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) + - `(RTPO8a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO8b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO8c)` If the resolved value is a `LiveObject` (i.e. a `LiveMap` or `LiveCounter`), returns a new `Instance` ([RTINS1](#RTINS1)) wrapping that `LiveObject` + - `(RTPO8d)` If the resolved value is a primitive, returns undefined/null + - `(RTPO8e)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) - `(RTPO9)` `PathObject#entries` function: - - `(RTPO9a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO9b)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) and returns an array of `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` - - `(RTPO9c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array + - `(RTPO9a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO9b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO9c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) and returns an array of `[key, PathObject]` pairs, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` + - `(RTPO9d)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array - `(RTPO10)` `PathObject#keys` function: - - `(RTPO10a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO10b)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) - - `(RTPO10c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array + - `(RTPO10a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO10b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO10c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) + - `(RTPO10d)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array - `(RTPO11)` `PathObject#values` function: - - `(RTPO11a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO11b)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) and returns an array of `PathObject`s, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` - - `(RTPO11c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array + - `(RTPO11a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO11b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO11c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) and returns an array of `PathObject`s, where each `PathObject` is created as if by calling `PathObject#get` with the corresponding key on this `PathObject` + - `(RTPO11d)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns an empty array - `(RTPO12)` `PathObject#size` function: - - `(RTPO12a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO12b)` If the resolved value is a `LiveMap`, delegates to `LiveMap#size` ([RTLM10](#RTLM10)) - - `(RTPO12c)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns undefined/null + - `(RTPO12a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO12b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO12c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#size` ([RTLM10](#RTLM10)) + - `(RTPO12d)` If the resolved value is not a `LiveMap`, or if path resolution fails, returns undefined/null - `(RTPO13)` `PathObject#compact` function: - - `(RTPO13a)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) - - `(RTPO13b)` If the resolved value is a `LiveMap`, returns a recursively compacted representation as a plain key-value object: - - `(RTPO13b1)` Each entry in the `LiveMap` is included in the result. Tombstoned entries are excluded - - `(RTPO13b2)` Nested `LiveMap` values are recursively compacted into nested plain key-value objects - - `(RTPO13b3)` Nested `LiveCounter` values are resolved to their numeric value - - `(RTPO13b4)` Primitive values (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`) are included as-is - - `(RTPO13b5)` Cyclic references (a `LiveMap` that has already been visited during this compaction) are represented by reusing the same in-memory object reference to the already-compacted result for that `LiveMap` - - `(RTPO13c)` If the resolved value is a `LiveCounter`, returns its current numeric value (equivalent to `PathObject#value`) - - `(RTPO13d)` If the resolved value is a primitive, returns the value directly (equivalent to `PathObject#value`) - - `(RTPO13e)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) + - `(RTPO13a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO13b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)) + - `(RTPO13c)` If the resolved value is a `LiveMap`, returns a recursively compacted representation as a plain key-value object: + - `(RTPO13c1)` Each entry in the `LiveMap` is included in the result. Tombstoned entries are excluded + - `(RTPO13c2)` Nested `LiveMap` values are recursively compacted into nested plain key-value objects + - `(RTPO13c3)` Nested `LiveCounter` values are resolved to their numeric value + - `(RTPO13c4)` Primitive values (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`) are included as-is + - `(RTPO13c5)` Cyclic references (a `LiveMap` that has already been visited during this compaction) are represented by reusing the same in-memory object reference to the already-compacted result for that `LiveMap` + - `(RTPO13d)` If the resolved value is a `LiveCounter`, returns its current numeric value (equivalent to `PathObject#value`) + - `(RTPO13e)` If the resolved value is a primitive, returns the value directly (equivalent to `PathObject#value`) + - `(RTPO13f)` If path resolution fails, returns undefined/null per [RTPO3c1](#RTPO3c1) - `(RTPO14)` `PathObject#compactJson` function: - - `(RTPO14a)` Behaves identically to `PathObject#compact` ([RTPO13](#RTPO13)) except for the following differences, which ensure the result is JSON-serializable: - - `(RTPO14a1)` `Binary` values are encoded as base64 strings instead of being included as-is - - `(RTPO14a2)` Cyclic references are represented as an object with a single `objectId` property containing the Object ID of the referenced `LiveMap`, instead of reusing the in-memory object reference + - `(RTPO14a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO14b)` Behaves identically to `PathObject#compact` ([RTPO13](#RTPO13)) except for the following differences, which ensure the result is JSON-serializable: + - `(RTPO14b1)` `Binary` values are encoded as base64 strings instead of being included as-is + - `(RTPO14b2)` Cyclic references are represented as an object with a single `objectId` property containing the Object ID of the referenced `LiveMap`, instead of reusing the in-memory object reference - `(RTPO15)` `PathObject#set` function: - `(RTPO15a)` Expects the following arguments: - `(RTPO15a1)` `key` `String` - the key to set the value for - `(RTPO15a2)` `value` - the value to assign to the key. Accepted types are the same as for `LiveMap#set` ([RTLM20](#RTLM20)) - - `(RTPO15b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) - - `(RTPO15c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#set` ([RTLM20](#RTLM20)) with the provided `key` and `value` - - `(RTPO15d)` If the resolved value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that the operation is not supported for the resolved object type + - `(RTPO15b)` Checks the write API preconditions per [RTO26](#RTO26) + - `(RTPO15c)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) + - `(RTPO15d)` If the resolved value is a `LiveMap`, delegates to `LiveMap#set` ([RTLM20](#RTLM20)) with the provided `key` and `value` + - `(RTPO15e)` If the resolved value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that the operation is not supported for the resolved object type - `(RTPO16)` `PathObject#remove` function: - `(RTPO16a)` Expects the following arguments: - `(RTPO16a1)` `key` `String` - the key to remove the value for - - `(RTPO16b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) - - `(RTPO16c)` If the resolved value is a `LiveMap`, delegates to `LiveMap#remove` ([RTLM21](#RTLM21)) with the provided `key` - - `(RTPO16d)` If the resolved value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + - `(RTPO16b)` Checks the write API preconditions per [RTO26](#RTO26) + - `(RTPO16c)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) + - `(RTPO16d)` If the resolved value is a `LiveMap`, delegates to `LiveMap#remove` ([RTLM21](#RTLM21)) with the provided `key` + - `(RTPO16e)` If the resolved value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - `(RTPO17)` `PathObject#increment` function: - `(RTPO17a)` Expects the following arguments: - `(RTPO17a1)` `amount` `Number` (optional) - the amount by which to increment the counter value. Defaults to 1 - - `(RTPO17b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) - - `(RTPO17c)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#increment` ([RTLC12](#RTLC12)) with the provided `amount` - - `(RTPO17d)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + - `(RTPO17b)` Checks the write API preconditions per [RTO26](#RTO26) + - `(RTPO17c)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) + - `(RTPO17d)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#increment` ([RTLC12](#RTLC12)) with the provided `amount` + - `(RTPO17e)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - `(RTPO18)` `PathObject#decrement` function: - `(RTPO18a)` Expects the following arguments: - `(RTPO18a1)` `amount` `Number` (optional) - the amount by which to decrement the counter value. Defaults to 1 - - `(RTPO18b)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) - - `(RTPO18c)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#decrement` ([RTLC13](#RTLC13)) with the provided `amount` - - `(RTPO18d)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + - `(RTPO18b)` Checks the write API preconditions per [RTO26](#RTO26) + - `(RTPO18c)` Resolves the path using the path resolution procedure ([RTPO3](#RTPO3)). On failure, throws per [RTPO3c2](#RTPO3c2) + - `(RTPO18d)` If the resolved value is a `LiveCounter`, delegates to `LiveCounter#decrement` ([RTLC13](#RTLC13)) with the provided `amount` + - `(RTPO18e)` If the resolved value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - `(RTPO19)` `PathObject#subscribe` function: - `(RTPO19a)` Expects the following arguments: - - `(RTPO19a1)` `listener` - a callback function that receives a `PathObjectSubscriptionEvent` ([RTPO19d](#RTPO19d)) + - `(RTPO19a1)` `listener` - a callback function that receives a `PathObjectSubscriptionEvent` ([RTPO19e](#RTPO19e)) - `(RTPO19a2)` `options` `PathObjectSubscriptionOptions` (optional) - subscription options - - `(RTPO19b)` `PathObjectSubscriptionOptions` has the following properties: - - `(RTPO19b1)` `depth` `Number` (optional) - controls how many levels deep in the subtree changes trigger the listener. Defaults to undefined/null. The `depth` value is interpreted by the subscription coverage rule in [RTO24c1](#RTO24c1); see [RTO24c2](#RTO24c2) for worked examples - - `(RTPO19b1a)` If `depth` is provided and is not a positive integer, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003 - - `(RTPO19c)` Returns a [`Subscription`](../features#SUB1) object - - `(RTPO19d)` The listener receives a `PathObjectSubscriptionEvent` object with: - - `(RTPO19d1)` `object` - a `PathObject` pointing to the path where the change occurred - - `(RTPO19d2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the [RTLO4b4](#RTLO4b4) emission that triggered this event is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted - - `(RTPO19e)` Adds a subscription to the `RealtimeObject`'s `PathObjectSubscriptionRegister` ([RTO24](#RTO24)) with subscribed path equal to this `PathObject`'s `path` (per [RTPO2a](#RTPO2a)), the provided `listener`, and the provided `options.depth` - - `(RTPO19f)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status + - `(RTPO19b)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO19c)` `PathObjectSubscriptionOptions` has the following properties: + - `(RTPO19c1)` `depth` `Number` (optional) - controls how many levels deep in the subtree changes trigger the listener. Defaults to undefined/null. The `depth` value is interpreted by the subscription coverage rule in [RTO24c1](#RTO24c1); see [RTO24c2](#RTO24c2) for worked examples + - `(RTPO19c1a)` If `depth` is provided and is not a positive integer, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003 + - `(RTPO19d)` Returns a [`Subscription`](../features#SUB1) object + - `(RTPO19e)` The listener receives a `PathObjectSubscriptionEvent` object with: + - `(RTPO19e1)` `object` - a `PathObject` pointing to the path where the change occurred + - `(RTPO19e2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the [RTLO4b4](#RTLO4b4) emission that triggered this event is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted + - `(RTPO19f)` Adds a subscription to the `RealtimeObject`'s `PathObjectSubscriptionRegister` ([RTO24](#RTO24)) with subscribed path equal to this `PathObject`'s `path` (per [RTPO2a](#RTPO2a)), the provided `listener`, and the provided `options.depth` + - `(RTPO19g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status ### Instance @@ -987,66 +1007,79 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS3a)` If the wrapped value is a `LiveObject`, returns the `objectId` of that object - `(RTINS3b)` If the wrapped value is a primitive, returns undefined/null - `(RTINS4)` `Instance#value` function: - - `(RTINS4a)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#value` ([RTLC5](#RTLC5)) - - `(RTINS4b)` If the wrapped value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns the value directly - - `(RTINS4c)` If the wrapped value is a `LiveMap`, returns undefined/null + - `(RTINS4a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS4b)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#value` ([RTLC5](#RTLC5)) + - `(RTINS4c)` If the wrapped value is a primitive (`Boolean`, `Binary`, `Number`, `String`, `JsonArray`, `JsonObject`), returns the value directly + - `(RTINS4d)` If the wrapped value is a `LiveMap`, returns undefined/null - `(RTINS5)` `Instance#get` function: - `(RTINS5a)` Expects the following arguments: - `(RTINS5a1)` `key` `String` - the key to look up - - `(RTINS5b)` If the wrapped value is a `LiveMap`, looks up the value at `key` using `LiveMap#get` ([RTLM5](#RTLM5)) and returns a new `Instance` wrapping the result. If the result is undefined/null, returns undefined/null - - `(RTINS5c)` If the wrapped value is not a `LiveMap`, returns undefined/null + - `(RTINS5b)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS5c)` If the wrapped value is a `LiveMap`, looks up the value at `key` using `LiveMap#get` ([RTLM5](#RTLM5)) and returns a new `Instance` wrapping the result. If the result is undefined/null, returns undefined/null + - `(RTINS5d)` If the wrapped value is not a `LiveMap`, returns undefined/null - `(RTINS6)` `Instance#entries` function: - - `(RTINS6a)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#entries` ([RTLM11](#RTLM11)) and returns an array of `[key, Instance]` pairs, where each `Instance` wraps the corresponding value - - `(RTINS6b)` If the wrapped value is not a `LiveMap`, returns an empty array + - `(RTINS6a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS6b)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#entries` ([RTLM11](#RTLM11)) and returns an array of `[key, Instance]` pairs, where each `Instance` wraps the corresponding value + - `(RTINS6c)` If the wrapped value is not a `LiveMap`, returns an empty array - `(RTINS7)` `Instance#keys` function: - - `(RTINS7a)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) - - `(RTINS7b)` If the wrapped value is not a `LiveMap`, returns an empty array + - `(RTINS7a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS7b)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#keys` ([RTLM12](#RTLM12)) + - `(RTINS7c)` If the wrapped value is not a `LiveMap`, returns an empty array - `(RTINS8)` `Instance#values` function: - - `(RTINS8a)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#values` ([RTLM13](#RTLM13)) and returns an array of `Instance`s, where each `Instance` wraps the corresponding value - - `(RTINS8b)` If the wrapped value is not a `LiveMap`, returns an empty array + - `(RTINS8a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS8b)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#values` ([RTLM13](#RTLM13)) and returns an array of `Instance`s, where each `Instance` wraps the corresponding value + - `(RTINS8c)` If the wrapped value is not a `LiveMap`, returns an empty array - `(RTINS9)` `Instance#size` function: - - `(RTINS9a)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#size` ([RTLM10](#RTLM10)) - - `(RTINS9b)` If the wrapped value is not a `LiveMap`, returns undefined/null + - `(RTINS9a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS9b)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#size` ([RTLM10](#RTLM10)) + - `(RTINS9c)` If the wrapped value is not a `LiveMap`, returns undefined/null - `(RTINS10)` `Instance#compact` function: - - `(RTINS10a)` Behaves identically to `PathObject#compact` ([RTPO13](#RTPO13)), but operates on the wrapped value directly instead of resolving a path + - `(RTINS10a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS10b)` Behaves identically to `PathObject#compact` ([RTPO13](#RTPO13)), but operates on the wrapped value directly instead of resolving a path - `(RTINS11)` `Instance#compactJson` function: - - `(RTINS11a)` Behaves identically to `PathObject#compactJson` ([RTPO14](#RTPO14)), but operates on the wrapped value directly instead of resolving a path + - `(RTINS11a)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS11b)` Behaves identically to `PathObject#compactJson` ([RTPO14](#RTPO14)), but operates on the wrapped value directly instead of resolving a path - `(RTINS12)` `Instance#set` function: - `(RTINS12a)` Expects the following arguments: - `(RTINS12a1)` `key` `String` - the key to set the value for - `(RTINS12a2)` `value` - the value to assign to the key. Accepted types are the same as for `LiveMap#set` ([RTLM20](#RTLM20)) - - `(RTINS12b)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#set` ([RTLM20](#RTLM20)) with the provided `key` and `value` - - `(RTINS12c)` If the wrapped value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + - `(RTINS12b)` Checks the write API preconditions per [RTO26](#RTO26) + - `(RTINS12c)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#set` ([RTLM20](#RTLM20)) with the provided `key` and `value` + - `(RTINS12d)` If the wrapped value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - `(RTINS13)` `Instance#remove` function: - `(RTINS13a)` Expects the following arguments: - `(RTINS13a1)` `key` `String` - the key to remove the value for - - `(RTINS13b)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#remove` ([RTLM21](#RTLM21)) with the provided `key` - - `(RTINS13c)` If the wrapped value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + - `(RTINS13b)` Checks the write API preconditions per [RTO26](#RTO26) + - `(RTINS13c)` If the wrapped value is a `LiveMap`, delegates to `LiveMap#remove` ([RTLM21](#RTLM21)) with the provided `key` + - `(RTINS13d)` If the wrapped value is not a `LiveMap`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - `(RTINS14)` `Instance#increment` function: - `(RTINS14a)` Expects the following arguments: - `(RTINS14a1)` `amount` `Number` (optional) - the amount by which to increment the counter value. Defaults to 1 - - `(RTINS14b)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#increment` ([RTLC12](#RTLC12)) with the provided `amount` - - `(RTINS14c)` If the wrapped value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + - `(RTINS14b)` Checks the write API preconditions per [RTO26](#RTO26) + - `(RTINS14c)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#increment` ([RTLC12](#RTLC12)) with the provided `amount` + - `(RTINS14d)` If the wrapped value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - `(RTINS15)` `Instance#decrement` function: - `(RTINS15a)` Expects the following arguments: - `(RTINS15a1)` `amount` `Number` (optional) - the amount by which to decrement the counter value. Defaults to 1 - - `(RTINS15b)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#decrement` ([RTLC13](#RTLC13)) with the provided `amount` - - `(RTINS15c)` If the wrapped value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 + - `(RTINS15b)` Checks the write API preconditions per [RTO26](#RTO26) + - `(RTINS15c)` If the wrapped value is a `LiveCounter`, delegates to `LiveCounter#decrement` ([RTLC13](#RTLC13)) with the provided `amount` + - `(RTINS15d)` If the wrapped value is not a `LiveCounter`, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007 - `(RTINS16)` `Instance#subscribe` function: - `(RTINS16a)` Expects the following arguments: - - `(RTINS16a1)` `listener` - a callback function that receives an `InstanceSubscriptionEvent` ([RTINS16d](#RTINS16d)) when the wrapped object is updated - - `(RTINS16b)` If the wrapped value is not a `LiveObject` (i.e. it is a primitive), the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that subscribe is not supported for primitive values - - `(RTINS16c)` Subscribes to data updates on the underlying `LiveObject` using `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) - - `(RTINS16d)` The listener receives an `InstanceSubscriptionEvent` object with: - - `(RTINS16d1)` `object` - an `Instance` wrapping the underlying `LiveObject` - - `(RTINS16d2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the underlying `LiveObject#subscribe` notification is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted - - `(RTINS16e)` Returns a [`Subscription`](../features#SUB1) object - - `(RTINS16f)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the tree - - `(RTINS16g)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status + - `(RTINS16a1)` `listener` - a callback function that receives an `InstanceSubscriptionEvent` ([RTINS16e](#RTINS16e)) when the wrapped object is updated + - `(RTINS16b)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTINS16c)` If the wrapped value is not a `LiveObject` (i.e. it is a primitive), the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 92007, indicating that subscribe is not supported for primitive values + - `(RTINS16d)` Subscribes to data updates on the underlying `LiveObject` using `LiveObject#subscribe` ([RTLO4b](#RTLO4b)) + - `(RTINS16e)` The listener receives an `InstanceSubscriptionEvent` object with: + - `(RTINS16e1)` `object` - an `Instance` wrapping the underlying `LiveObject` + - `(RTINS16e2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the underlying `LiveObject#subscribe` notification is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted + - `(RTINS16f)` Returns a [`Subscription`](../features#SUB1) object + - `(RTINS16g)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the tree + - `(RTINS16h)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status ### PublicAPI::ObjectMessage -- `(PAOM1)` A `PublicAPI::ObjectMessage` is the user-facing representation of an inbound `ObjectMessage` ([OM1](../features#OM1)) that carried an operation. It is delivered to user subscription listeners (see [RTPO19d2](#RTPO19d2), [RTINS16d2](#RTINS16d2)) so that user code can inspect the metadata of the message that triggered an object change. The `PublicAPI::` prefix is used to avoid a name clash with `ObjectMessage`; SDKs expose this type to users as `ObjectMessage`. +- `(PAOM1)` A `PublicAPI::ObjectMessage` is the user-facing representation of an inbound `ObjectMessage` ([OM1](../features#OM1)) that carried an operation. It is delivered to user subscription listeners (see [RTPO19e2](#RTPO19e2), [RTINS16e2](#RTINS16e2)) so that user code can inspect the metadata of the message that triggered an object change. The `PublicAPI::` prefix is used to avoid a name clash with `ObjectMessage`; SDKs expose this type to users as `ObjectMessage`. - `(PAOM2)` The attributes available in a `PublicAPI::ObjectMessage` are: - `(PAOM2a)` `id` string (optional) - the `id` ([OM2a](../features#OM2a)) of the source `ObjectMessage` - `(PAOM2b)` `clientId` string (optional) - the `clientId` ([OM2b](../features#OM2b)) of the source `ObjectMessage` @@ -1166,16 +1199,16 @@ Types and their properties/methods are public and exposed to users by default. A entries: Dict? // RTLMV2a, internal static create(Dict entries?) -> LiveMapValueType // RTLMV3 - interface PathObjectSubscriptionEvent: // RTPO19d - object: PathObject // RTPO19d1 - message: PublicAPI::ObjectMessage? // RTPO19d2 + interface PathObjectSubscriptionEvent: // RTPO19e + object: PathObject // RTPO19e1 + message: PublicAPI::ObjectMessage? // RTPO19e2 - interface PathObjectSubscriptionOptions: // RTPO19b - depth: Number? // RTPO19b1 + interface PathObjectSubscriptionOptions: // RTPO19c + depth: Number? // RTPO19c1 - interface InstanceSubscriptionEvent: // RTINS16d - object: Instance // RTINS16d1 - message: PublicAPI::ObjectMessage? // RTINS16d2 + interface InstanceSubscriptionEvent: // RTINS16e + object: Instance // RTINS16e1 + message: PublicAPI::ObjectMessage? // RTINS16e2 class PublicAPI::ObjectMessage: // PAOM* id: String? // PAOM2a From 83a0404f24443777956a0730cde62081ea581d0c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 27 May 2026 14:46:16 -0300 Subject: [PATCH 41/43] Fix RTPO2a/RTPO2b anchor links in RTO23d and RTO24b2b1 Both clauses linked `RTPO2a` and `RTPO2b` to `#RTPO2` (the parent clause). Point them at their own anchors instead, matching the convention already used by RTPO19f. Reported by @VeskeR on PR #427 [1] [2]. [1] https://github.com/ably/specification/pull/427#discussion_r3312385410 [2] https://github.com/ably/specification/pull/427#discussion_r3312403752 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 1de79f00a..6b97046c8 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -19,7 +19,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO23a)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - `(RTO23b)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - `(RTO23c)` If the [RTO17](#RTO17) sync state is not `SYNCED`, waits for the sync state to transition to `SYNCED` - - `(RTO23d)` Returns a new `PathObject` ([RTPO1](#RTPO1)) with `path` ([RTPO2a](#RTPO2)) set to an empty list and `root` ([RTPO2b](#RTPO2)) set to the `LiveMap` with id `root` from the internal `ObjectsPool` + - `(RTO23d)` Returns a new `PathObject` ([RTPO1](#RTPO1)) with `path` ([RTPO2a](#RTPO2a)) set to an empty list and `root` ([RTPO2b](#RTPO2b)) set to the `LiveMap` with id `root` from the internal `ObjectsPool` - `(RTO11)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11a)` This clause has been replaced by [RTLMV3](#RTLMV3). - `(RTO11a1)` This clause has been replaced by [RTLMV3](#RTLMV3). @@ -307,7 +307,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO24b2a1)` The first (most-preferred) candidate is `pathToThis` itself - `(RTO24b2a2)` If the `LiveObjectUpdate` is a `LiveMapUpdate`, then for each key in `LiveMapUpdate.update`, append a further candidate consisting of `pathToThis` extended by that key - `(RTO24b2b)` For each registered subscription, find the first `eventPath` in `candidatePaths` that the subscription covers per [RTO24c1](#RTO24c1). If no such `eventPath` exists, do nothing for this subscription. Otherwise, call the subscription's listener exactly once with a `PathObjectSubscriptionEvent` that has: - - `(RTO24b2b1)` `object` - a new `PathObject` ([RTPO1](#RTPO1)) with `path` ([RTPO2a](#RTPO2)) set to `eventPath` and `root` ([RTPO2b](#RTPO2)) set to the `LiveMap` with id `root` from the internal `ObjectsPool` + - `(RTO24b2b1)` `object` - a new `PathObject` ([RTPO1](#RTPO1)) with `path` ([RTPO2a](#RTPO2a)) set to `eventPath` and `root` ([RTPO2b](#RTPO2b)) set to the `LiveMap` with id `root` from the internal `ObjectsPool` - `(RTO24b2b2)` `message` - if `LiveObjectUpdate.objectMessage` is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` derived from `LiveObjectUpdate.objectMessage` per [PAOM3](#PAOM3); otherwise omitted - `(RTO24b2c)` If a listener throws an error, the error must be caught and logged without affecting the dispatch to other subscriptions, nor to other `pathToThis` iterations - `(RTO24c)` Subscription coverage: From 9e1cd88157e33951e19e47bfe75aa45646b1396a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 27 May 2026 14:46:37 -0300 Subject: [PATCH 42/43] Clarify what the "final" ObjectMessage represents In RTLMV4d2 and RTLM20e7g2, the spec instructs implementers to take the objectId from the "final" ObjectMessage produced by recursively evaluating a LiveMapValueType (or LiveCounterValueType). "Final" is unambiguous as the last element of an ordered array, but doesn't on its own explain why that particular message is the right one to reference. Spell out that it is the create operation for the LiveObject whose creation the value type represents. Reported by @VeskeR on PR #427 [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3312475594 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 6b97046c8..65e82524c 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -599,7 +599,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM20e7a)` This clause has been replaced by [RTLM20e7g](#RTLM20e7g). - `(RTLM20e7g)` If the `value` is of type `LiveCounterValueType` or `LiveMapValueType`: - `(RTLM20e7g1)` Evaluate the value type per [RTLCV4](#RTLCV4) or [RTLMV4](#RTLMV4) respectively. Collect all generated `ObjectMessages` into an ordered list — for [RTLCV4](#RTLCV4) the list contains the single returned `ObjectMessage`; for [RTLMV4](#RTLMV4) the list is the returned array - - `(RTLM20e7g2)` Set `ObjectMessage.operation.mapSet.value.objectId` to the `objectId` from the final `ObjectMessage` in the list gathered in [`RTLM20e7g1`](#RTLM20e7g1) + - `(RTLM20e7g2)` Set `ObjectMessage.operation.mapSet.value.objectId` to the `objectId` from the final `ObjectMessage` in the list gathered in [`RTLM20e7g1`](#RTLM20e7g1) (which is the create operation for the `LiveObject` whose creation the value type represents) - `(RTLM20e7b)` If the `value` is of type `JsonArray` or `JsonObject`, set `ObjectMessage.operation.mapSet.value.json` to that value - `(RTLM20e7c)` If the `value` is of type `String`, set `ObjectMessage.operation.mapSet.value.string` to that value - `(RTLM20e7d)` If the `value` is of type `Number`, set `ObjectMessage.operation.mapSet.value.number` to that value @@ -842,7 +842,7 @@ A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` obje - `(RTLMV4c)` If any of the values in the internal `entries` are not of an expected type, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 40013, indicating that such data type is unsupported - `(RTLMV4d)` Build entries for the `MapCreate` object. For each key-value pair in the internal `entries` (if present), create an `ObjectsMapEntry` for the value: - `(RTLMV4d1)` If the value is of type `LiveCounterValueType`, evaluate it per [RTLCV4](#RTLCV4) to generate a `COUNTER_CREATE` `ObjectMessage`. Collect the generated `ObjectMessage` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the `ObjectMessage` - - `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively evaluate it per [RTLMV4](#RTLMV4) to generate an ordered array of `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the final `ObjectMessage` in the array + - `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively evaluate it per [RTLMV4](#RTLMV4) to generate an ordered array of `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the final `ObjectMessage` in the array (which is the `MAP_CREATE` for the `LiveMap` whose creation this `LiveMapValueType` represents, per [RTLMV4k](#RTLMV4k); earlier entries create objects nested within it) - `(RTLMV4d3)` If the value is of type `JsonArray` or `JsonObject`, set `ObjectsMapEntry.data.json` to that value - `(RTLMV4d4)` If the value is of type `String`, set `ObjectsMapEntry.data.string` to that value - `(RTLMV4d5)` If the value is of type `Number`, set `ObjectsMapEntry.data.number` to that value From fdd80da7519c1a069c4197e72dd7e550b3b2e5b6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 27 May 2026 15:01:30 -0300 Subject: [PATCH 43/43] Replace "tree" with "graph" in LiveObjects data-model descriptions The LiveObjects data model is a directed graph that may contain cycles (cyclic structures can be created via the REST API), not a tree. RTLO4f already frames it explicitly in graph-theoretic terms. Several descriptions of `PathObject`, `Instance`, and `getFullPaths` introduced in this PR referred to it as a tree; switch them to "graph" (and reword the "subtree" reference in RTPO19c1, where depth is actually about path nesting below the subscription path, not tree depth from the root). Reported by @VeskeR on PR #427 [1]. [1] https://github.com/ably/specification/pull/427#discussion_r3310679192 Co-Authored-By: Claude Opus 4.7 (1M context) --- specifications/objects-features.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 65e82524c..dbd63b5f0 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -333,7 +333,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLO3d1)` Set to `false` when the `LiveObject` is initialized - `(RTLO3e)` protected `tombstonedAt` (optional) Time - a timestamp indicating when this object was tombstoned. This property is nullable, and specification points that manipulate this value maintain the invariant that it is non-null if and only if `isTombstone` is `true` - `(RTLO3e1)` Set to undefined/null when the `LiveObject` is initialized - - `(RTLO3f)` protected `parentReferences` `Dict>` - tracks which `LiveMap`s in the local `ObjectsPool` currently reference this `LiveObject`, and at which keys they do so. The mapping is keyed by the parent `LiveMap`'s `objectId`, with each value being the set of keys at which that `LiveMap` references this `LiveObject`. Used by `getFullPaths` ([RTLO4f](#RTLO4f)) to determine every path the object currently occupies in the LiveObjects tree + - `(RTLO3f)` protected `parentReferences` `Dict>` - tracks which `LiveMap`s in the local `ObjectsPool` currently reference this `LiveObject`, and at which keys they do so. The mapping is keyed by the parent `LiveMap`'s `objectId`, with each value being the set of keys at which that `LiveMap` references this `LiveObject`. Used by `getFullPaths` ([RTLO4f](#RTLO4f)) to determine every path the object currently occupies in the LiveObjects graph - `(RTLO3f1)` This mapping is keyed by `objectId` for consistency with the rest of the LiveObjects spec, where references between objects are stored as `objectId`s and resolved via the `ObjectsPool` on demand. Implementations may store a direct reference to the parent `LiveMap` instead — for example to avoid an `ObjectsPool` lookup at each step of `getFullPaths` ([RTLO4f](#RTLO4f)) traversal — provided the observable behaviour is unchanged. Such implementations should be aware that this may introduce reference cycles between `LiveMap`s, and must ensure this does not cause memory leaks - `(RTLO3f2)` Set to an empty map when the `LiveObject` is initialized - `(RTLO4)` `LiveObject` methods: @@ -867,16 +867,16 @@ A `LiveMapValueType` is an immutable blueprint for creating a new `LiveMap` obje ### PathObject -A `PathObject` is a lazy, path-based reference into the LiveObjects tree. It stores a path (as an ordered list of string segments) from the root `LiveMap` and resolves it at the time each method is called. This means a `PathObject` survives object replacements: if the object at a given path changes (e.g. via a `MAP_SET` operation), the same `PathObject` will resolve to the new object on subsequent calls. +A `PathObject` is a lazy, path-based reference into the LiveObjects graph. It stores a path (as an ordered list of string segments) from the root `LiveMap` and resolves it at the time each method is called. This means a `PathObject` survives object replacements: if the object at a given path changes (e.g. via a `MAP_SET` operation), the same `PathObject` will resolve to the new object on subsequent calls. A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which returns a `PathObject` rooted at the channel's root `LiveMap` with an empty path. Further `PathObjects` are obtained by navigating with `PathObject#get` or `PathObject#at`. -- `(RTPO1)` The `PathObject` class provides a path-based view over the LiveObjects tree +- `(RTPO1)` The `PathObject` class provides a path-based view over the LiveObjects graph - `(RTPO1a)` A specific SDK implementation may choose to expose a subset of the methods available on the `PathObject` class based on the expected type at the path. For example, when the user provides a type structure as a generic type parameter to `RealtimeObject#get`, the SDK may use type-specific class names (e.g. `LiveMapPathObject`, `LiveCounterPathObject`, `PrimitivePathObject`) that only expose the methods applicable to that type. The specification describes the general `PathObject` class with the full set of methods - `(RTPO2)` `PathObject` has the following internal properties: - - `(RTPO2a)` `path` - an ordered list of string segments representing the path from the root `LiveMap` to this position in the tree + - `(RTPO2a)` `path` - an ordered list of string segments representing the path from the root `LiveMap` to this position in the graph - `(RTPO2b)` `root` - a reference to the root `LiveMap` instance from the internal `ObjectsPool` -- `(RTPO3)` Internal path resolution procedure - resolves the stored `path` against the LiveObjects tree: +- `(RTPO3)` Internal path resolution procedure - resolves the stored `path` against the LiveObjects graph: - `(RTPO3a)` Starting from `root`, walk through the path segments in order. For each segment: - `(RTPO3a1)` The current object must be a `LiveMap`. If it is not, the resolution has failed - `(RTPO3a2)` Look up the segment as a key in the current `LiveMap` using `LiveMap#get` ([RTLM5](#RTLM5)). If the result is undefined/null, the resolution has failed @@ -986,7 +986,7 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re - `(RTPO19a2)` `options` `PathObjectSubscriptionOptions` (optional) - subscription options - `(RTPO19b)` Checks the access API preconditions per [RTO25](#RTO25) - `(RTPO19c)` `PathObjectSubscriptionOptions` has the following properties: - - `(RTPO19c1)` `depth` `Number` (optional) - controls how many levels deep in the subtree changes trigger the listener. Defaults to undefined/null. The `depth` value is interpreted by the subscription coverage rule in [RTO24c1](#RTO24c1); see [RTO24c2](#RTO24c2) for worked examples + - `(RTPO19c1)` `depth` `Number` (optional) - controls how many levels of path nesting below the subscription path trigger the listener. Defaults to undefined/null. The `depth` value is interpreted by the subscription coverage rule in [RTO24c1](#RTO24c1); see [RTO24c2](#RTO24c2) for worked examples - `(RTPO19c1a)` If `depth` is provided and is not a positive integer, the library must throw an `ErrorInfo` error with `statusCode` 400 and `code` 40003 - `(RTPO19d)` Returns a [`Subscription`](../features#SUB1) object - `(RTPO19e)` The listener receives a `PathObjectSubscriptionEvent` object with: @@ -997,7 +997,7 @@ A `PathObject` is obtained from `RealtimeObject#get` ([RTO23](#RTO23)), which re ### Instance -An `Instance` holds a direct reference to a specific resolved `LiveObject` or primitive value. Unlike `PathObject` which is path-addressed and re-resolves on each call, `Instance` is identity-addressed: it follows the specific object it was created with, regardless of where that object sits in the tree. +An `Instance` holds a direct reference to a specific resolved `LiveObject` or primitive value. Unlike `PathObject` which is path-addressed and re-resolves on each call, `Instance` is identity-addressed: it follows the specific object it was created with, regardless of where that object sits in the graph. - `(RTINS1)` The `Instance` class provides a direct-reference view of a `LiveObject` or primitive value - `(RTINS1a)` A specific SDK implementation may choose to expose a subset of the methods available on the `Instance` class based on the known underlying type. For example, the SDK may use type-specific class names (e.g. `LiveMapInstance`, `LiveCounterInstance`, `PrimitiveInstance`) that only expose the methods applicable to the wrapped type. The specification describes the general `Instance` class with the full set of methods @@ -1074,7 +1074,7 @@ An `Instance` holds a direct reference to a specific resolved `LiveObject` or pr - `(RTINS16e1)` `object` - an `Instance` wrapping the underlying `LiveObject` - `(RTINS16e2)` `message` `PublicAPI::ObjectMessage` (optional) - if `LiveObjectUpdate.objectMessage` from the underlying `LiveObject#subscribe` notification is populated and its `operation` field is populated, a `PublicAPI::ObjectMessage` ([PAOM1](#PAOM1)) derived from it per [PAOM3](#PAOM3); otherwise omitted - `(RTINS16f)` Returns a [`Subscription`](../features#SUB1) object - - `(RTINS16g)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the tree + - `(RTINS16g)` The subscription is identity-based: it follows the specific `LiveObject` instance, regardless of where it sits in the graph - `(RTINS16h)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status ### PublicAPI::ObjectMessage