From 051ba0331ba65771f5c5c1c1cfa0a44e9bc74081 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 24 Feb 2026 11:50:30 +0000 Subject: [PATCH 01/44] Rename entry point: `channel.objects.getRoot()` -> `channel.object.get()`, rename `RealtimeObjects` -> `RealtimeObject` Also update the plugins key name. All these to match the changes made in JS. Co-Authored-By: Lawrence Forooghian --- specifications/features.md | 20 ++++----- specifications/objects-features.md | 71 ++++++++++++++++-------------- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/specifications/features.md b/specifications/features.md index 51f634d1e..8c504fe9f 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -377,7 +377,7 @@ Support for the deprecated client options `environment`, `restHost`, `realtimeHo - `(PC2)` No generic plugin interface is specified, and therefore there is no common API exposed by all plugins. However, for type-safety, the opaque interface `Plugin` should be used in strongly-typed languages as the type of the `ClientOptions.plugins` collection as per [TO3o](#TO3o). - `(PC3)` A plugin provided with the `PluginType` enum key value of `vcdiff` should be capable of decoding "vcdiff"-encoded messages. It must implement the `VCDiffDecoder` interface and the client library must be able to use it by casting it to this interface. - `(PC3a)` The base argument of the `VCDiffDecoder.decode` method should receive the stored base payload of the last message on a channel as specified by [RTL19](#RTL19). If the base payload is a string it should be encoded to binary using UTF-8 before being passed as base argument of the `VCDiffDecoder.decode` method. -- `(PC5)` A plugin provided with the `PluginType` enum key value of `Objects` should provide the [RealtimeObjects](../objects-features#RTO1) feature functionality for realtime channels ([RTL27](#RTL27)). The plugin object itself is not expected to provide a public API. The type of the plugin object, and how it enables the Objects feature for a realtime channel, are left for individual implementations to decide. +- `(PC5)` A plugin provided with the `PluginType` enum key value of `LiveObjects` should provide the [RealtimeObject](../objects-features#RTO23) feature functionality for realtime channels ([RTL27](#RTL27)). The plugin object itself is not expected to provide a public API. The type of the plugin object, and how it enables the Objects feature for a realtime channel, are left for individual implementations to decide. - `(PC4)` A client library is allowed to accept plugins other than those specified in this specification, through the use of additional `ClientOptions.plugins` keys defined by that library. The library is responsible for defining the interface of these plugins, and for making sure that these keys do not clash with the keys defined in this specification. ### PluginType {#plugin-type} @@ -385,7 +385,7 @@ Support for the deprecated client options `environment`, `restHost`, `realtimeHo - `(PT1)` `PluginType` is an enum describing the different types of plugins that the library supports. See the `ClientOptions#plugins` property ([TO3o](#TO3o)). - `(PT2)` `PluginType` takes one of the following values: - `(PT2a)` `vcdiff` -- see [PC3](#PC3). - - `(PT2b)` `Objects` -- see [PC5](#PC5). + - `(PT2b)` `LiveObjects` -- see [PC5](#PC5). ### VCDiffDecoder {#vcdiff-decoder} @@ -673,7 +673,7 @@ The threading and/or asynchronous model for each realtime library will vary by l ### RealtimeChannel {#realtime-channel} - `(RTL23)` `RealtimeChannel#name` attribute is a string containing the channel's name -- `(RTL1)` As soon as a `RealtimeChannel` becomes attached, all incoming messages, presence messages and object messages (where 'incoming' is defined as 'received from Ably over the realtime transport') are processed and emitted where applicable. `PRESENCE` and `SYNC` messages are passed to the `RealtimePresence` object ensuring it maintains a map of current members on a channel in realtime. `OBJECT` and `OBJECT_SYNC` messages are passed to the `RealtimeObjects` object ensuring it maintains an up-to-date representation of objects on a channel in realtime +- `(RTL1)` As soon as a `RealtimeChannel` becomes attached, all incoming messages, presence messages and object messages (where 'incoming' is defined as 'received from Ably over the realtime transport') are processed and emitted where applicable. `PRESENCE` and `SYNC` messages are passed to the `RealtimePresence` object ensuring it maintains a map of current members on a channel in realtime. `OBJECT` and `OBJECT_SYNC` messages are passed to the `RealtimeObject` object ensuring it maintains an up-to-date representation of objects on a channel in realtime - `(RTL2)` The `RealtimeChannel` implements `EventEmitter` and emits `ChannelEvent` events, where a `ChannelEvent` is either a `ChannelState` or `UPDATE`, and a `ChannelState` is either `INITIALIZED`, `ATTACHING`, `ATTACHED`, `DETACHING`, `DETACHED`, `SUSPENDED` and `FAILED` - `(RTL2a)` It emits a `ChannelState` `ChannelEvent` for every channel state change - `(RTL2g)` It emits an `UPDATE` `ChannelEvent` for changes to channel conditions for which the `ChannelState` (e.g. `ATTACHED`) does not change, unless explicitly prevented by a more specific condition (see [RTL12](#RTL12)). (The library must never emit a `ChannelState` `ChannelEvent` for a state equal to the previous state) @@ -767,9 +767,9 @@ The threading and/or asynchronous model for each realtime library will vary by l - `(RTL8b)` Unsubscribe with a name argument and a listener argument unsubscribes the provided listener if previously subscribed with a name-specific subscription - `(RTL9)` `RealtimeChannel#presence` attribute: - `(RTL9a)` Returns the `RealtimePresence` object for this channel -- `(RTL27)` `RealtimeChannel#objects` attribute: - - `(RTL27a)` Returns the `RealtimeObjects` object for this channel [RTO1](../objects-features#RTO1) - - `(RTL27b)` It is a programmer error to access this property without first providing the `Objects` plugin ([PC5](#PC5)) in the client options. This programmer error should be handled in an idiomatic fashion; if this means accessing the property should throw an error, then the error should be an `ErrorInfo` with `statusCode` 400 and `code` 40019. +- `(RTL27)` `RealtimeChannel#object` attribute: + - `(RTL27a)` Returns the `RealtimeObject` object for this channel [RTO23](../objects-features#RTO23) + - `(RTL27b)` It is a programmer error to access this property without first providing the `LiveObjects` plugin ([PC5](#PC5)) in the client options. This programmer error should be handled in an idiomatic fashion; if this means accessing the property should throw an error, then the error should be an `ErrorInfo` with `statusCode` 400 and `code` 40019. - `(RTL10)` `RealtimeChannel#history` function: - `(RTL10a)` Supports all the same params as `RestChannel#history` - `(RTL10b)` Additionally supports the param `untilAttach`, which if true, will only retrieve messages prior to the moment that the channel was attached or emitted an `UPDATE` indicating loss of continuity. This bound is specified by passing the querystring param `fromSerial` with the `RealtimeChannel#properties.attachSerial` assigned to the channel in the `ATTACHED` `ProtocolMessage` (see [RTL15a](#RTL15a)). If the `untilAttach` param is specified when the channel is not attached, it results in an error @@ -937,9 +937,9 @@ The threading and/or asynchronous model for each realtime library will vary by l - `(RTP15e)` Implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED` state. However, if the channel is in or enters the `DETACHED` or `FAILED` state before the operation succeeds, it will result in an error - `(RTP15f)` If the client is identified and has a valid `clientId`, and the `clientId` argument does not match the client's `clientId`, then it should indicate an error. The connection and channel remain available for further operations -### RealtimeObjects {#realtime-objects} +### RealtimeObject {#realtime-objects} -Reserved for `RealtimeObjects` 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` ### RealtimeAnnotations {#realtime-annotations} @@ -2260,7 +2260,7 @@ Each type, method, and attribute is labelled with the name of one or more clause state: ChannelState // RTL2b whenState(ChannelState, (ChannelStateChange?) ->) // RTL25 presence: RealtimePresence // RTL9 - objects: RealtimeObjects // RTL27 + object: RealtimeObject // RTL27 properties: ChannelProperties // RTL15 // Only on platforms that support receiving push notifications: push: PushChannel // RSH7 @@ -2840,7 +2840,7 @@ Each type, method, and attribute is labelled with the name of one or more clause enum PluginType // PT* "vcdiff" // PT2a - "Objects" // PT2b + "LiveObjects" // PT2b class VCDiffDecoder // VD* decode([byte] delta, [byte] base) -> [byte] // VD2a, PC3a diff --git a/specifications/objects-features.md b/specifications/objects-features.md index 7a15e9cfe..92bb8daef 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -8,14 +8,19 @@ This document outlines the feature specification for the Objects feature of the Objects feature enables clients to store shared data as "objects" on a channel. When an object is updated, changes are automatically propagated to all subscribed clients in realtime, ensuring each client always sees the latest state. -### RealtimeObjects {#realtime-objects} - -- `(RTO1)` `RealtimeObjects#getRoot` function: - - `(RTO1a)` Requires the `OBJECT_SUBSCRIBE` channel mode to be granted per [RTO2](#RTO2) - - `(RTO1b)` If the channel is in the `DETACHED` or `FAILED` state, the library should throw an `ErrorInfo` error with `statusCode` 400 and `code` 90001 - - `(RTO1c)` If the [RTO17](#RTO17) sync state is not `SYNCED`, waits for the sync state to transition to `SYNCED` - - `(RTO1d)` Returns the object with id `root` from the internal `ObjectsPool` as a `LiveMap` -- `(RTO11)` `RealtimeObjects#createMap` function: +### RealtimeObject {#realtime-objects} + +- `(RTO1)` This clause has been replaced by [RTO23](#RTO23). + - `(RTO1a)` This clause has been replaced by [RTO23a](#RTO23a). + - `(RTO1b)` This clause has been replaced by [RTO23b](#RTO23b). + - `(RTO1c)` This clause has been replaced by [RTO23c](#RTO23c). + - `(RTO1d)` This clause has been replaced by [RTO23d](#RTO23d). +- `(RTO23)` `RealtimeObject#get` function: + - `(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` +- `(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 @@ -66,7 +71,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(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. - `(RTO11g)` This clause has been replaced by [RTO11i](#RTO11i) - - `(RTO11i)` Publishes the `ObjectMessage` from [RTO11f](#RTO11f) using `RealtimeObjects#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array + - `(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: - `(RTO11h1)` This clause has been deleted. @@ -76,7 +81,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(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)` `RealtimeObjects#createCounter` function: +- `(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 @@ -110,7 +115,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. `(RTO12g)` This clause has been replaced by [RTO12i](#RTO12i) -`(RTO12i)` Publishes the `ObjectMessage` from [RTO12f](#RTO12f) using `RealtimeObjects#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array +`(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 @@ -198,14 +203,14 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO6b2)` If the parsed type is `map`, create a new `LiveMap` per [RTLM4](#RTLM4) by passing in the `objectId`, and add it to the `ObjectsPool` - `(RTO6b3)` If the parsed type is `counter`, create a new `LiveCounter` per [RTLC4](#RTLC4) by passing in the `objectId`, and add it to the `ObjectsPool` - `(RTO7)` The client library may receive `OBJECT` `ProtocolMessages` in realtime over the channel concurrently with `OBJECT_SYNC` `ProtocolMessages` during the object sync sequence ([RTO5](#RTO5)). Some of the incoming `OBJECT` messages may have already been applied to the objects described in the sync sequence, while others may not. Therefore, the client must buffer `OBJECT` messages during the sync sequence so that it can determine which of them should be applied to the objects once the sync is complete. See [RTO8](#RTO8) - - `(RTO7a)` The `RealtimeObjects` instance has an internal attribute `bufferedObjectOperations`, which is an array of `ObjectMessage` instances. This is used to store the buffered `ObjectMessages`, as described in [RTO8a](#RTO8a). - - `(RTO7a1)` This array is empty upon `RealtimeObjects` initialization - - `(RTO7b)` The `RealtimeObjects` instance has an internal attribute `appliedOnAckSerials`, which is a set of strings. This is used to store the serial values of operations that have been applied upon receipt of an `ACK` but for which the echo has not yet been received. - - `(RTO7b1)` This set is empty upon `RealtimeObjects` initialization -- `(RTO8)` When the library receives a `ProtocolMessage` with an action of `OBJECT`, each member of the `ProtocolMessage.state` array (decoded into `ObjectMessage` objects) is passed to the `RealtimeObjects` instance per [RTL1](../features#RTL1). Each `ObjectMessage` from `OBJECT` `ProtocolMessage` (also referred to as an `OBJECT` message) describes an operation to be applied to an object on a channel and must be handled as follows: + - `(RTO7a)` The `RealtimeObject` instance has an internal attribute `bufferedObjectOperations`, which is an array of `ObjectMessage` instances. This is used to store the buffered `ObjectMessages`, as described in [RTO8a](#RTO8a). + - `(RTO7a1)` This array is empty upon `RealtimeObject` initialization + - `(RTO7b)` The `RealtimeObject` instance has an internal attribute `appliedOnAckSerials`, which is a set of strings. This is used to store the serial values of operations that have been applied upon receipt of an `ACK` but for which the echo has not yet been received. + - `(RTO7b1)` This set is empty upon `RealtimeObject` initialization +- `(RTO8)` When the library receives a `ProtocolMessage` with an action of `OBJECT`, each member of the `ProtocolMessage.state` array (decoded into `ObjectMessage` objects) is passed to the `RealtimeObject` instance per [RTL1](../features#RTL1). Each `ObjectMessage` from `OBJECT` `ProtocolMessage` (also referred to as an `OBJECT` message) describes an operation to be applied to an object on a channel and must be handled as follows: - `(RTO8a)` If the [RTO17](#RTO17) sync state is not `SYNCED`, add the `ObjectMessages` to the internal `bufferedObjectOperations` array - `(RTO8b)` Otherwise, apply the `ObjectMessages` as described in [RTO9](#RTO9), passing `source` as `CHANNEL` -- `(RTO9)` `OBJECT` messages can be applied to `RealtimeObjects` in the following way: +- `(RTO9)` `OBJECT` messages can be applied to `RealtimeObject` in the following way: - `(RTO9b)` Expects the following arguments: - `(RTO9b1)` `ObjectMessage[]` - the list of `ObjectMessages` to apply - `(RTO9b2)` `source` `ObjectsOperationSource` - the source of the operation (see [RTO22](#RTO22)) @@ -244,7 +249,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO14b1)` Generate a SHA-256 digest from a UTF-8 encoded string in the format `[initialValue]:[nonce]` - `(RTO14b2)` Base64URL-encode the generated digest. This must follow the URL-safe Base64 encoding as described in [RFC 4648 s.5](https://datatracker.ietf.org/doc/html/rfc4648#section-5), not standard Base64 encoding - `(RTO14c)` Return an Object ID in the format `[type]:[hash]@[timestamp]`, where `timestamp` is represented as milliseconds since the epoch -- `(RTO15)` Internal `RealtimeObjects#publish` function: +- `(RTO15)` Internal `RealtimeObject#publish` function: - `(RTO15a)` Expects the following arguments: - `(RTO15a1)` `ObjectMessage[]` - an array of `ObjectMessage` to be published on a channel - `(RTO15b)` Must adhere to the same connection and channel state conditions as message publishing, see [RTL6c](../features#RTL6c) @@ -257,10 +262,10 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO15f)` Must send the `ProtocolMessage` to the connection - `(RTO15g)` Must indicate success or failure of the publish (once `ACKed` or `NACKed`) in the same way as `RealtimeChannel#publish` - `(RTO15h)` Upon success, must return the `PublishResult` from the first element of the `ACK`'s `res` array ([TR4s](../features#TR4s)), in the same way as `RealtimeChannel#publish` ([RTL6j](../features#RTL6j)) -- `(RTO20)` Internal `RealtimeObjects#publishAndApply` function: +- `(RTO20)` Internal `RealtimeObject#publishAndApply` function: - `(RTO20a)` Expects the following arguments: - `(RTO20a1)` `ObjectMessage[]` - an array of `ObjectMessage` to be published on a channel - - `(RTO20b)` Calls `RealtimeObjects#publish` ([RTO15](#RTO15)) with the provided `ObjectMessage[]` and awaits the `PublishResult`. If `publish` fails, rethrow the error and do not proceed + - `(RTO20b)` Calls `RealtimeObject#publish` ([RTO15](#RTO15)) with the provided `ObjectMessage[]` and awaits the `PublishResult`. If `publish` fails, rethrow the error and do not proceed - `(RTO20c)` If the information needed to apply the operations locally on ACK is not available, this is unexpected incorrect behaviour from the server. Log an error indicating the reason the operations will not be applied locally, and do not proceed with the remaining steps. (The operations have already been published successfully, but cannot be applied locally on ACK; they will instead be applied when the echoed messages are received from the server.) The required information is: - `(RTO20c1)` A `siteCode` from [CD2j](../features#CD2j) `ConnectionDetails.siteCode` - `(RTO20c2)` A `PublishResult.serials` array with the same length as the provided `ObjectMessage[]` argument @@ -275,13 +280,13 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO20f)` Apply the synthetic `ObjectMessages` as described in [RTO9](#RTO9), passing `source` as `LOCAL` - `(RTO16)` Server time can be retrieved using [`RestClient#time`](../features#RSC16) - `(RTO16a)` The server time offset can be persisted by the client library and used to calculate the server time without making a request, in a similar way to how it is described in [RSA10k](../features#RSA10k). The persisted offset from either operation can be used interchangeably -- `(RTO17)` The `RealtimeObjects` instance must maintain an internal sync state to track the status of synchronising the local objects data with the Ably service. +- `(RTO17)` The `RealtimeObject` instance must maintain an internal sync state to track the status of synchronising the local objects data with the Ably service. - `(RTO17a)` The sync state has type `ObjectsSyncState`, which is an enum with the following cases (note that their descriptions are purely informative; the rules for state transitions are described elsewhere in this specification): - - `(RTO17a1)` `INITIALIZED` - the initial state when `RealtimeObjects` is created + - `(RTO17a1)` `INITIALIZED` - the initial state when `RealtimeObject` is created - `(RTO17a2)` `SYNCING` - in this state, the local copy of objects on the channel is currently being synchronised with the Ably service - `(RTO17a3)` `SYNCED` - in this state, the local copy of objects on the channel has been synchronised with the Ably service - - `(RTO17b)` When the sync state transitions, an event with the `ObjectsEvent` value matching the new state must be emitted to any listeners registered via `RealtimeObjects#on` ([RTO18](#RTO18)). -- `(RTO18)` `RealtimeObjects#on` function - registers a listener for sync state events + - `(RTO17b)` When the sync state transitions, an event with the `ObjectsEvent` value matching the new state must be emitted to any listeners registered via `RealtimeObject#on` ([RTO18](#RTO18)). +- `(RTO18)` `RealtimeObject#on` function - registers a listener for sync state events - `(RTO18a)` Expects the following arguments: - `(RTO18a1)` `event` - the event name to listen for, of type `ObjectsEvent` (see [RTO18b](#RTO18b)) - `(RTO18a2)` `callback` - the event listener function to be called when the event is emitted @@ -294,7 +299,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTO18f)` The client library may return a subscription object (or the idiomatic equivalent for the language) as a result of this operation: - `(RTO18f1)` The subscription object includes an `off` function - `(RTO18f2)` Calling `off` deregisters the listener previously registered by the user via the corresponding `on` call -- `(RTO19)` `RealtimeObjects#off` function - deregisters an event listener previously registered via `RealtimeObjects#on` ([RTO18](#RTO18)) +- `(RTO19)` `RealtimeObject#off` function - deregisters an event listener previously registered via `RealtimeObject#on` ([RTO18](#RTO18)) - `(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 @@ -328,12 +333,12 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(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 - - `(RTLO4b6)` This operation must not have any side effects on `RealtimeObjects`, the underlying channel, or their status + - `(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 - `(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 `RealtimeObjects`, the underlying channel, or their status + - `(RTLO4c4)` This operation must not have any side effects on `RealtimeObject`, the underlying channel, or their status - `(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` @@ -389,7 +394,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLC12e4)` This clause has been replaced by [RTLC12e5](#RTLC12e5) as of specification version 6.0.0. - `(RTLC12e5)` Set `ObjectMessage.operation.counterInc.number` to the provided `amount` value - `(RTLC12f)` This clause has been replaced by [RTLC12g](#RTLC12g) - - `(RTLC12g)` Publishes the `ObjectMessage` from [RTLC12e](#RTLC12e) using `RealtimeObjects#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array + - `(RTLC12g)` Publishes the `ObjectMessage` from [RTLC12e](#RTLC12e) using `RealtimeObject#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array - `(RTLC13)` `LiveCounter#decrement` function: - `(RTLC13a)` Expects the following arguments: - `(RTLC13a1)` `amount` `Number` - the amount by which to decrement the counter value @@ -548,7 +553,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(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 `RealtimeObjects#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array + - `(RTLM20g)` Publishes the `ObjectMessage` from [RTLM20e](#RTLM20e) using `RealtimeObject#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` 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 @@ -562,7 +567,7 @@ Objects feature enables clients to store shared data as "objects" on a channel. - `(RTLM21e4)` This clause has been replaced by [RTLM21e5](#RTLM21e5) as of specification version 6.0.0. - `(RTLM21e5)` Set `ObjectMessage.operation.mapRemove.key` to the provided `key` value - `(RTLM21f)` This clause has been replaced by [RTLM21g](#RTLM21g) - - `(RTLM21g)` Publishes the `ObjectMessage` from [RTLM21e](#RTLM21e) using `RealtimeObjects#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array + - `(RTLM21g)` Publishes the `ObjectMessage` from [RTLM21e](#RTLM21e) using `RealtimeObject#publishAndApply` ([RTO20](#RTO20)), passing the `ObjectMessage` as a single element in the array - `(RTLM14)` An `ObjectsMapEntry` in the internal `data` map can be checked for being tombstoned using the convenience method: - `(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` @@ -719,11 +724,11 @@ Objects feature enables clients to store shared data as "objects" on a channel. ## Interface Definition {#idl} -Describes types for RealtimeObjects.\ +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 RealtimeObjects: // RTO* - getRoot() => io LiveMap // RTO1 + class RealtimeObject: // RTO* + get() => io LiveMap // RTO23 createMap(Dict entries?) => io LiveMap // RTO11 createCounter(Number count?) => io LiveCounter // RTO12 on(ObjectsEvent event, (() ->) callback) -> StatusSubscription // RTO18 From a41641104fc2572743fa7693a9aea7647a281dcd Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 24 Feb 2026 11:59:02 +0000 Subject: [PATCH 02/44] 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 8c504fe9f..25091786e 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 dd11843c7eddcd9198120e79674b3f9cf4b0fbdc Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 24 Feb 2026 12:12:31 +0000 Subject: [PATCH 03/44] 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 25091786e..823606b54 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} @@ -1333,13 +1333,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 f59e8f9eac3538ebaa27ce4a27e18fe46c381306 Mon Sep 17 00:00:00 2001 From: Andrew Bulat Date: Tue, 24 Feb 2026 13:04:55 +0000 Subject: [PATCH 04/44] 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 823606b54..da3771a45 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1872,6 +1872,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 @@ -2926,6 +2932,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 7d2e7ea2005f4447131d5e3610779319db6d5597 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 15:14:46 -0300 Subject: [PATCH 05/44] 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 157fddd84487bd563c216d91ee313f21afc290dd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 15:19:14 -0300 Subject: [PATCH 06/44] 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 da192dd547d6f69488a8d26be6eb10f07d85b1ee Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 15:41:03 -0300 Subject: [PATCH 07/44] 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 dea8cf0923fd17bc919302881148845ed5ce2708 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 16:22:17 -0300 Subject: [PATCH 08/44] 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 466892dec9fd945a8dae0d20f47b5fcbe2d9762d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 16:48:09 -0300 Subject: [PATCH 09/44] 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 da3771a45..9f78d1663 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1876,7 +1876,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 09cf185157d7f55b5de68efdf2a8d714776026da Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 20 May 2026 17:07:56 -0300 Subject: [PATCH 10/44] 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 ed71a2f20912ab40e63d952f1782fc3075850f50 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 08:44:09 -0300 Subject: [PATCH 11/44] 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 2b64b35a8906d2ed5cae1a8030d0ecbf5b51a67c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 08:57:45 -0300 Subject: [PATCH 12/44] 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 ffbcb658675b48f080dafc6b3ec7ffc7dbaeeb51 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 08:58:10 -0300 Subject: [PATCH 13/44] 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 9cc1bc8614dc77c88b6b8bdfb419a2e7dc37e9f6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 09:22:19 -0300 Subject: [PATCH 14/44] 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 99334f47fb99d6b2d4beae25a10e5b1d6bd1a9ae Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 13:31:58 -0300 Subject: [PATCH 15/44] 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 dd25afd9e373dd42a606e17ff81ee48a2ca01f64 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 15:20:24 -0300 Subject: [PATCH 16/44] 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 2eb4f81e38ce5d21d91179f90db65d89697bd8ba Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 16:04:17 -0300 Subject: [PATCH 17/44] 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 9e8ce98331fb39aaf5708872bcd43f83ce1c5fd9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 May 2026 17:25:09 -0300 Subject: [PATCH 18/44] 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 583222288265ec2b1afc56b79d2147ddd0b83989 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 08:57:06 -0300 Subject: [PATCH 19/44] 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 037de995568c87b641f3e25163788d8b3d357ec7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 09:13:12 -0300 Subject: [PATCH 20/44] 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 535eaee0f35cd1a0f11e3c1a77690317b014d9b6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 09:27:00 -0300 Subject: [PATCH 21/44] 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 9f78d1663..b3c72604f 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1877,6 +1877,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 294b7b1ce28f1f0dacfc1e2eb2dc51a0b0d9b225 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 09:29:51 -0300 Subject: [PATCH 22/44] 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 32e2351916a516c8a12aca0ec610df0030fcdaf9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 10:50:17 -0300 Subject: [PATCH 23/44] 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 c83ce6376144b273672be43081c535595437433b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 11:02:47 -0300 Subject: [PATCH 24/44] 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 d524ec4ce8f6f62ae3c85e50eb28db54ab910dc1 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 13:52:57 -0300 Subject: [PATCH 25/44] 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 a12ba5380e5f41ba11186c9323f5a593a6f205c0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 14:22:48 -0300 Subject: [PATCH 26/44] 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 807d3c5400865c402540f7e9af604fa3fa689064 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 14:26:51 -0300 Subject: [PATCH 27/44] 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 3edaf71e308f034affb1cc1e7762896e3cd37415 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 May 2026 15:15:59 -0300 Subject: [PATCH 28/44] 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 4a8e2931378e4477d0d76528c25b92306f2f71b1 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 15:04:58 -0300 Subject: [PATCH 29/44] 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 103f5b91caac107ae551e7d0bdc7e2041a17814c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:00:29 -0300 Subject: [PATCH 30/44] 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 a5bde95909fe9e214ca40a239d4671a3e6133e55 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:05:33 -0300 Subject: [PATCH 31/44] 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 084d7d184d1d4fb4e15225f50e28b2ddcae97fc7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:07:16 -0300 Subject: [PATCH 32/44] 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 cc093b538bf27b88d67c106a9a747adff13ae153 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:08:22 -0300 Subject: [PATCH 33/44] 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 713296217f1f4e0e8641e550f439e3859795be63 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:31:34 -0300 Subject: [PATCH 34/44] 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 aae86c368e4231f1b1b6f34f50569280cb965dc6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 16:50:14 -0300 Subject: [PATCH 35/44] 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 1e56adead5a73074fbb2246785a6021422043825 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 17:02:00 -0300 Subject: [PATCH 36/44] 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 4ec119b5a357c83c67439707f3847e88459fc79a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 17:09:57 -0300 Subject: [PATCH 37/44] 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 4dc800d138d05eb6f6af70e00f32e53a4d790e8b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 17:21:20 -0300 Subject: [PATCH 38/44] 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 f399e44fd4cddcf44c0285e17ddcfed570151971 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 17:29:12 -0300 Subject: [PATCH 39/44] 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 de3574fd93a1b3e61fe3a03b7f4e4fda7483d8fd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 18:11:37 -0300 Subject: [PATCH 40/44] 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 69ae754ba81ade5b01f3b42aa2ea376d1f2ae2ac Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Sun, 24 May 2026 22:06:30 -0300 Subject: [PATCH 41/44] 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 d2fe1bdff7f0e5e25571a4d4e07e765fd487435e Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Wed, 13 May 2026 11:12:04 +0100 Subject: [PATCH 42/44] Add UTS test specs for LiveObjects path-based API (~330 tests) Complete portable test suite covering the LiveObjects path-based API: 21 files across unit tests (pure + mock WebSocket), integration tests (sandbox), and proxy integration tests. Covers PathObject, Instance, BatchContext, LiveCounter/LiveMap CRDTs, ObjectsPool sync state machine, value types, subscriptions, and GC. Includes table-driven validation tests, bytes/binary data coverage, and REST fixture provisioning. Co-Authored-By: Claude Opus 4.6 --- uts/objects/PLAN.md | 379 +++++++ uts/objects/helpers/standard_test_pool.md | 322 ++++++ uts/objects/integration/objects_batch_test.md | 201 ++++ uts/objects/integration/objects_gc_test.md | 138 +++ .../integration/objects_lifecycle_test.md | 317 ++++++ uts/objects/integration/objects_sync_test.md | 200 ++++ .../integration/proxy/objects_faults.md | 459 ++++++++ uts/objects/unit/batch.md | 782 ++++++++++++++ uts/objects/unit/instance.md | 524 ++++++++++ uts/objects/unit/live_counter.md | 824 +++++++++++++++ uts/objects/unit/live_counter_api.md | 343 ++++++ uts/objects/unit/live_map.md | 980 ++++++++++++++++++ uts/objects/unit/live_map_api.md | 483 +++++++++ uts/objects/unit/live_object_subscribe.md | 244 +++++ uts/objects/unit/object_id.md | 159 +++ uts/objects/unit/objects_pool.md | 910 ++++++++++++++++ uts/objects/unit/path_object.md | 603 +++++++++++ uts/objects/unit/path_object_mutations.md | 321 ++++++ uts/objects/unit/path_object_subscribe.md | 618 +++++++++++ uts/objects/unit/realtime_object.md | 927 +++++++++++++++++ uts/objects/unit/value_types.md | 451 ++++++++ 21 files changed, 10185 insertions(+) create mode 100644 uts/objects/PLAN.md create mode 100644 uts/objects/helpers/standard_test_pool.md create mode 100644 uts/objects/integration/objects_batch_test.md create mode 100644 uts/objects/integration/objects_gc_test.md create mode 100644 uts/objects/integration/objects_lifecycle_test.md create mode 100644 uts/objects/integration/objects_sync_test.md create mode 100644 uts/objects/integration/proxy/objects_faults.md create mode 100644 uts/objects/unit/batch.md create mode 100644 uts/objects/unit/instance.md create mode 100644 uts/objects/unit/live_counter.md create mode 100644 uts/objects/unit/live_counter_api.md create mode 100644 uts/objects/unit/live_map.md create mode 100644 uts/objects/unit/live_map_api.md create mode 100644 uts/objects/unit/live_object_subscribe.md create mode 100644 uts/objects/unit/object_id.md create mode 100644 uts/objects/unit/objects_pool.md create mode 100644 uts/objects/unit/path_object.md create mode 100644 uts/objects/unit/path_object_mutations.md create mode 100644 uts/objects/unit/path_object_subscribe.md create mode 100644 uts/objects/unit/realtime_object.md create mode 100644 uts/objects/unit/value_types.md diff --git a/uts/objects/PLAN.md b/uts/objects/PLAN.md new file mode 100644 index 000000000..3cc547856 --- /dev/null +++ b/uts/objects/PLAN.md @@ -0,0 +1,379 @@ +# UTS Test Specs for LiveObjects Path-Based API + +## Context + +The LiveObjects feature lets clients store shared CRDT data on realtime channels. The specification is at `specification/specifications/objects-features.md` — specifically the path-based API version on branch `origin/AIT-30/liveobjects-path-based-api-spec` (with batch API additions on `origin/AIT-30/liveobjects-batch-api`). + +An earlier attempt at UTS test specs exists in `uts/test/realtime/unit/objects/` (14 files). It was written against a different spec namespace (PO* vs RTPO*/RTINS*/RTLCV*/RTLMV*), used v5 wire format field names, had apply-on-ACK contradictions, and duplicated setup across files. We're doing a clean rewrite using the correct spec, informed by that earlier work. + +All new test files go in `specification/uts/objects/`. + +## Spec Architecture Summary + +**Internal (not user-facing):** LiveObject, LiveCounter (CRDT counter), LiveMap (LWW map), ObjectsPool (sync state machine), RealtimeObject (channel orchestrator with publishAndApply) + +**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounterValueType/LiveMapValueType (creation descriptors via static `create()` factories), BatchContext (atomic multi-op publish) + +**Wire protocol v6:** `counterInc.number`, `mapSet.{key,value}`, `mapRemove.key`, `mapCreate.{semantics,entries}`, `counterCreateWithObjectId.{nonce,initialValue}`, `mapCreateWithObjectId.{nonce,initialValue}` + +**REST API:** Not specified in objects-features.md. ably-js has REST object tests but those are implementation-specific, not spec'd. No REST test files needed. + +--- + +## File Organization + +### Helper +| File | Purpose | +|------|---------| +| `helpers/standard_test_pool.md` | Shared: standard ObjectsPool fixture, protocol message builders, synced-channel setup pattern | + +### Pure Unit Tests (no mocks) +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~28 | +| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~42 | +| `unit/objects_pool.md` | RTO3-9 | ~35 | +| `unit/object_id.md` | RTO14 | ~5 | +| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (consumption generates ObjectMessages with v6 wire format) | ~19 | + +### Mock WebSocket Unit Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-24 (sync events, publish, publishAndApply, mode checks, GC) | ~33 | +| `unit/live_counter_api.md` | RTLC5, RTLC11-13 (value, increment, decrement through channel) | ~13 | +| `unit/live_map_api.md` | RTLM5, RTLM10-13, RTLM20-21, RTLM24 (reads + mutations through channel, echoMessages check) | ~18 | +| `unit/live_object_subscribe.md` | RTLO4b, RTLO4c (subscribe/unsubscribe on internal LiveObject) | ~8 | +| `unit/path_object.md` | RTPO1-14 (navigation, value, instance, entries, compact, compactJson) | ~33 | +| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2 (set, remove, increment, decrement, error on unresolvable path) | ~12 | +| `unit/path_object_subscribe.md` | RTPO19-21, RTO24 (path subscriptions, depth filtering, path-following semantics, subscribeIterator) | ~20 | +| `unit/instance.md` | RTINS1-18 (id, value, get, entries, size, compact, set, remove, increment, subscribe) | ~26 | +| `unit/batch.md` | RTPO22, RTINS19, RTBC1-16 (batch entry, BatchContext methods, RootBatchContext flush/close) | ~20 | + +### Integration Tests (sandbox) +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `integration/objects_lifecycle_test.md` | RTO23, RTPO15, RTPO17 (create objects, mutate via PathObject, read back, REST provisioning) | ~6 | +| `integration/objects_sync_test.md` | RTO4, RTO5, RTO17 (attach, sync sequence, re-attach) | ~4 | +| `integration/objects_batch_test.md` | RTPO22, RTBC12-15 (batch publish, atomic delivery) | ~3 | +| `integration/objects_gc_test.md` | RTO10, RTLM19 (behavioral GC verification with ADVANCE_TIME) | ~2 | + +### Proxy Integration Tests +| File | Spec Points | ~Tests | +|------|-------------|--------| +| `integration/proxy/objects_faults.md` | RTO5a2, RTO7, RTO8, RTO17, RTO20e (sync interruption, mutation buffering during re-sync, server-initiated detach, publish failure on FAILED channel, publish during delayed sync) | ~5 | + +**Totals: ~21 files, ~330 tests** + +--- + +## Helper Spec Design + +### `helpers/standard_test_pool.md` + +**Standard test tree:** +``` +root (LiveMap, objectId: "root") + +-- "name" -> string "Alice" + +-- "age" -> number 30 + +-- "active" -> boolean true + +-- "score" -> objectId "counter:score@1000" + +-- "profile" -> objectId "map:profile@1000" + +-- "data" -> json {"tags": ["a", "b"]} + +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) + +counter:score@1000 (LiveCounter, data: 100) + +map:profile@1000 (LiveMap) + +-- "email" -> string "alice@example.com" + +-- "nested_counter" -> objectId "counter:nested@1000" + +-- "prefs" -> objectId "map:prefs@1000" + +counter:nested@1000 (LiveCounter, data: 5) + +map:prefs@1000 (LiveMap) + +-- "theme" -> string "dark" +``` + +**Builder functions:** +- `build_object_sync_message(channel, channelSerial, objectMessages[])` -> OBJECT_SYNC ProtocolMessage +- `build_object_message(channel, objectMessages[])` -> OBJECT ProtocolMessage +- `build_ack_message(msgSerial, serials[])` -> ACK ProtocolMessage with `res: [{ serials }]` +- `build_counter_inc(objectId, number, serial, siteCode)` -> ObjectMessage +- `build_map_set(objectId, key, value, serial, siteCode)` -> ObjectMessage +- `build_map_remove(objectId, key, serial, siteCode, serialTimestamp?)` -> ObjectMessage +- `build_map_clear(objectId, serial, siteCode)` -> ObjectMessage +- `build_object_delete(objectId, serial, siteCode, serialTimestamp?)` -> ObjectMessage +- `build_counter_create(objectId, counterCreate, serial, siteCode)` -> ObjectMessage +- `build_map_create(objectId, mapCreate, serial, siteCode)` -> ObjectMessage +- `build_object_state(objectId, siteTimeserials, {map?, counter?, tombstone?, createOp?})` -> ObjectMessage wrapping ObjectState + +**Standard synced-channel pattern** (referenced by all mock-WS test files): +```pseudo +setup_synced_channel(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, + channelSerial: "attach-serial-1", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + ELSE IF msg.action == OBJECT: + // Auto-ACK with generated serials + serials = msg.state.map((_, i) => "ack-serial-" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } + ) + install_mock(mock_ws) + client = Realtime(options: {key: "fake:key", autoConnect: true}) + channel = client.channels.get(channel_name, {modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"]}) + root = AWAIT channel.object.get() + RETURN {client, channel, root, mock_ws} +``` + +--- + +## Pure Unit Test Design + +### `unit/live_counter.md` -- CRDT Counter Data Structure + +Directly construct `LiveCounter`, call `applyOperation()` and `replaceData()`, assert internal state. + +**Key test groups:** +1. **Zero value (RTLC4):** data=0, siteTimeserials={}, createOperationIsMerged=false, isTombstone=false +2. **COUNTER_INC (RTLC9):** adds `counterInc.number` to data; noop when number missing +3. **COUNTER_CREATE (RTLC8/RTLC16):** merges `counterCreate.count`; noop when already merged +4. **Newness check (RTLO4a):** empty siteSerial allows apply; stale serial rejected; empty serial/siteCode logs warning +5. **siteTimeserials (RTLC7c):** CHANNEL source updates map; LOCAL source does not +6. **applyOperation returns bool (RTLC7g):** true on success, false on rejection/tombstone +7. **Tombstone (RTLC7e, RTLO4e, RTLO5):** OBJECT_DELETE tombstones; ops on tombstoned counter rejected +8. **replaceData (RTLC6):** full replacement; tombstone handling; createOp merge; diff calculation +9. **tombstonedAt (RTLO6):** from serialTimestamp if present, else local clock + +### `unit/live_map.md` -- LWW Map Data Structure + +Same pattern. Key additional concerns: + +1. **MAP_SET (RTLM7):** new entry, existing entry update, LWW rejection, clearTimeserial floor (RTLM7h), objectId creates zero-value object (RTLM7g) +2. **MAP_REMOVE (RTLM8):** tombstones entry, sets tombstonedAt via RTLO6, clearTimeserial floor (RTLM8g) +3. **MAP_CLEAR (RTLM24):** sets clearTimeserial, removes entries with serial <= clear serial, preserves newer entries +4. **Entry-level LWW (RTLM9):** 5 serial comparison cases +5. **MAP_CREATE (RTLM16/RTLM23):** merges entries via individual MAP_SET/MAP_REMOVE calls +6. **replaceData (RTLM6):** sets clearTimeserial from ObjectState.map.clearTimeserial (RTLM6i) +7. **get/size/entries (RTLM5/RTLM10/RTLM11):** value resolution, tombstone filtering, objectId reference resolution +8. **GC (RTLM19):** removes tombstoned entries past grace period +9. **Diff (RTLM22):** non-tombstoned entry comparison + +### `unit/objects_pool.md` -- Pool + Sync State Machine + +Directly construct ObjectsPool, call `processAttached()`, `processObjectSync()`, `processObjectMessage()`. + +1. **Initialization (RTO3):** root LiveMap always present +2. **ATTACHED handling (RTO4):** HAS_OBJECTS -> SYNCING; no flag -> clear pool + immediate SYNCED +3. **OBJECT_SYNC sequence (RTO5/RTO5f):** accumulate in SyncObjectsPool; partial merge (RTO5f2a); cursor parsing; new sequence discards old (RTO5a2) +4. **Sync completion (RTO5c):** replace existing (RTO5c1a), create new (RTO5c1b), remove absent (RTO5c2), emit updates (RTO5c7), apply buffered ops (RTO5c6), clear appliedOnAckSerials (RTO5c9), transition to SYNCED (RTO5c8) +5. **Buffering (RTO7/RTO8):** OBJECT messages buffered during SYNCING, applied when SYNCED +6. **Operation application (RTO9):** appliedOnAckSerials dedup (RTO9a3), LOCAL source adds to set (RTO9a2a4), null op warning (RTO9a1), unsupported action warning (RTO9a2b) +7. **Zero-value creation (RTO6):** infer type from objectId prefix +8. **GC (RTO10):** tombstoned objects removed after grace period + +### `unit/object_id.md` -- ObjectId Generation (RTO14) + +Pure function tests: +1. Format: `{type}:{base64url(SHA-256(initialValue:nonce))}@{timestamp}` +2. SHA-256 of UTF-8 `{initialValue}:{nonce}` -> base64url (RFC 4648 s.5) +3. `map` and `counter` type prefixes +4. Deterministic: same inputs -> same objectId +5. Different nonce -> different objectId + +### `unit/value_types.md` -- LiveCounterValueType / LiveMapValueType + +Tests the static `create()` factories and consumption procedure. + +**LiveCounterValueType (RTLCV1-4):** +1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 +2. `LiveCounter.create()` -> count defaults to 0 +3. Consumption: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` +4. Non-number count throws 40003 during consumption + +**LiveMapValueType (RTLMV1-4):** +1. `LiveMap.create({entries})` -> immutable LiveMapValueType +2. Consumption: validates keys/values, builds entries, generates objectId, returns ObjectMessage with `mapCreateWithObjectId.{nonce, initialValue}` +3. Nested value types: LiveMapValueType containing LiveCounterValueType -> depth-first ObjectMessage array (inner creates before outer) +4. Retains local MapCreate/CounterCreate alongside wire format (RTLMV4j5/RTLCV4g5) + +--- + +## Mock WebSocket Test Design + +### `unit/realtime_object.md` -- Orchestration + +Uses `setup_synced_channel()` from helper. + +**Key tests:** +- **RTO23:** get() requires OBJECT_SUBSCRIBE, throws on DETACHED/FAILED, waits for SYNCED, returns PathObject +- **RTO2:** channel mode enforcement (granted vs requested modes) +- **RTO15/RTO15h:** publish sends OBJECT PM, returns PublishResult from ACK res array +- **RTO20:** publishAndApply: publishes, constructs synthetic messages with siteCode from ConnectionDetails, applies with source=LOCAL, adds to appliedOnAckSerials +- **RTO20c:** fails gracefully when siteCode or serials missing +- **RTO20d1:** null serial in PublishResult (conflated op) is skipped +- **RTO20e:** waits for SYNCED during SYNCING; fails with 92008 if channel enters DETACHED/SUSPENDED/FAILED +- **RTO17/RTO18/RTO19:** sync state events, on/off registration +- **RTO10:** GC with fake timers + ADVANCE_TIME + +### `unit/path_object.md` -- Read Operations + +- **RTPO4:** path() string representation with dot escaping +- **RTPO5/RTPO6:** get(key) / at("a.b.c") -- pure navigation, no resolution +- **RTPO7:** value() -- counter returns number, primitive returns value, LiveMap returns null, unresolvable returns null +- **RTPO8:** instance() -- LiveObject returns Instance, primitive returns null +- **RTPO9-11:** entries/keys/values -- yields [key, PathObject] pairs for LiveMap entries +- **RTPO12:** size() -- non-tombstoned entry count +- **RTPO13:** compact() -- recursive, cycle detection with shared object references +- **RTPO14:** compactJson() -- binary as base64, cycles as {objectId: ...} +- **RTPO3:** path resolution (RTPO3a): walk segments through LiveMaps; fail if intermediate not LiveMap + +### `unit/path_object_mutations.md` -- Write Operations + +- **RTPO15:** set(value) -- constructs ObjectMessages, calls publishAndApply +- **RTPO16:** remove() -- constructs MAP_REMOVE ObjectMessage +- **RTPO17:** increment(n) -- constructs COUNTER_INC ObjectMessage +- **RTPO18:** decrement(n) -- delegates to increment(-n) +- **RTPO3c2:** mutation on unresolvable path throws 92007 + +### `unit/path_object_subscribe.md` -- Path-Based Subscriptions + +- **RTPO19:** subscribe returns Subscription, listener receives PathObjectSubscriptionEvent +- **RTPO19b1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) +- **RTPO19b1d:** non-positive depth throws 40003 +- **RTPO19e:** follows path not identity -- object replacement at path -> subscription tracks new object +- **RTPO19f:** child events bubble up to parent subscription +- **RTO24b3:** depth formula: `eventPath.length - subscriptionPath.length + 1 <= depth` +- **RTO24b5:** listener exception caught, doesn't affect other listeners +- **RTPO20:** unsubscribe deregisters + +### `unit/instance.md` -- Identity-Bound Reference + +- **RTINS1:** id property returns objectId +- **RTINS2:** value() -- counter returns number, map returns null +- **RTINS3-5:** get(key), entries(), keys(), values() -- delegate to underlying LiveMap +- **RTINS6:** size() -- non-tombstoned entry count +- **RTINS7:** compact() -- recursive with cycle detection +- **RTINS8:** compactJson() +- **RTINS9-12:** set, remove, increment, decrement -- construct ObjectMessages, call publishAndApply +- **RTINS13-16:** subscribe/unsubscribe with depth filtering +- **RTINS17:** instance follows identity not path -- object replacement at path doesn't affect Instance +- **RTINS18:** operations on tombstoned Instance throw error + +### `unit/live_counter_api.md` -- Counter Through Channel + +- **RTLC5:** value property returns current data +- **RTLC11/RTLC12:** increment/decrement construct correct v6 wire ObjectMessage +- **RTLC12d:** echoMessages=false skips publishAndApply, uses publish +- **RTLC13:** increment with non-number throws 40003 + +### `unit/live_map_api.md` -- Map Through Channel + +- **RTLM5:** get(key) returns resolved value +- **RTLM10/RTLM11:** entries/keys/values iterate non-tombstoned entries +- **RTLM12/RTLM13:** set/remove construct correct v6 wire ObjectMessages +- **RTLM20:** set with LiveCounterValueType/LiveMapValueType consumes value type +- **RTLM20d/RTLM21d:** echoMessages=false uses publish instead of publishAndApply +- **RTLM24:** clear constructs MAP_CLEAR ObjectMessage + +### `unit/live_object_subscribe.md` -- Internal Subscription + +- **RTLO4b:** subscribe(listener) registers on internal LiveObject +- **RTLO4c:** unsubscribe removes listener +- Events fire on applyOperation with update details + +### `unit/batch.md` -- Batch API + +- **RTPO22/RTINS19:** batch entry points -- resolve to LiveObject, create RootBatchContext, execute fn, flush +- **RTPO22c/RTINS19c:** unresolvable path / non-LiveObject throws 92007 +- **RTBC3-11:** read methods delegate to Instance (id, value, get, entries, keys, values, size, compact, compactJson) +- **RTBC4d:** get() wraps result via RootBatchContext#wrapInstance (memoized by objectId -- RTBC16c) +- **RTBC12-15:** write methods (set, remove, increment, decrement) queue message constructors synchronously +- **RTBC16d:** flush executes constructors, publishes all as single array via RTO15 (NOT publishAndApply) +- **RTBC16e:** closed batch throws 40000 on any method call +- **RTBC16f:** RootBatchContext closed after flush regardless of success/failure + +--- + +## Apply-on-ACK Testing Strategy + +The RTO20 publishAndApply flow: +1. Client publishes OBJECT PM +2. Server returns ACK with `res: [{ serials: [...] }]` +3. Client constructs synthetic inbound ObjectMessages (serial + siteCode from ConnectionDetails) +4. Applies via RTO9 with source=LOCAL -> adds serials to `appliedOnAckSerials` +5. When echoed OBJECT PM arrives with same serial -> RTO9a3 deduplicates and removes from set + +**Mock WS handler for mutation tests:** +```pseudo +onMessageFromClient: (msg) => { + IF msg.action == OBJECT: + serials = [] + FOR i IN 0..msg.state.length-1: + serials.append("ack-" + msg.msgSerial + "-" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) +} +``` + +**Tests verify:** +1. After `AWAIT pathObject.set(...)`, local state reflects the change +2. The correct OBJECT PM was sent (v6 wire format) +3. When echo arrives with same serial, no double-application +4. If ACK arrives during SYNCING (RTO20e), publishAndApply waits for SYNCED + +--- + +## Dependency Ordering (write order) + +1. `helpers/standard_test_pool.md` +2. `unit/live_counter.md` -- no dependencies +3. `unit/live_map.md` -- no dependencies +4. `unit/object_id.md` -- no dependencies +5. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts +6. `unit/value_types.md` -- uses objectId generation +7. `unit/realtime_object.md` -- uses helper, tests orchestration +8. `unit/live_counter_api.md` -- uses helper +9. `unit/live_map_api.md` -- uses helper +10. `unit/live_object_subscribe.md` -- uses helper +11. `unit/path_object.md` -- uses helper +12. `unit/instance.md` -- uses helper +13. `unit/path_object_mutations.md` -- uses helper +14. `unit/path_object_subscribe.md` -- uses helper +15. `unit/batch.md` -- uses helper, depends on PathObject/Instance concepts +16. `integration/objects_lifecycle_test.md` +17. `integration/objects_sync_test.md` +18. `integration/objects_batch_test.md` +19. `integration/objects_gc_test.md` +20. `integration/proxy/objects_faults.md` + +--- + +## Key Decisions + +| Decision | Rationale | +|----------|-----------| +| Wire format v6 everywhere | Spec branch uses v6 field names; old v5 names are "replaced by" stubs | +| `appliedOnAckSerials` on RealtimeObject (RTO7b), not on pool | Matches spec's placement; cleared at sync completion (RTO5c9) | +| No REST test files | objects-features.md has no REST API spec points; REST used only for integration fixture provisioning | +| `echoMessages` check retained on mutations | Spec retains RTLC12d, RTLM20d, RTLM21d | +| Batch uses RTO15 (publish), NOT RTO20 (publishAndApply) | RTBC16d says "publishes ... using `RealtimeObject#publish`" -- batch does NOT apply locally on ACK | +| LiveObject/LiveMap/LiveCounter marked internal but still unit-tested | Direct testing of CRDT logic is essential; public API tests can't cover all edge cases | +| Test IDs use `objects/unit/` prefix | Matches directory structure, not nested under `realtime/` | +| Behavioral GC testing via ADVANCE_TIME | Verify GC through observable consequences (value becomes null, object recreatable) rather than internal pool state inspection | +| Table-driven tests for input validation | Use FOR loops over scenario arrays (like ably-js forScenarios) to test all invalid/valid type combinations | +| Bytes data type coverage | Standard test pool includes "avatar" bytes entry; compact/compactJson/value tests verify base64 encoding | diff --git a/uts/objects/helpers/standard_test_pool.md b/uts/objects/helpers/standard_test_pool.md new file mode 100644 index 000000000..e01062903 --- /dev/null +++ b/uts/objects/helpers/standard_test_pool.md @@ -0,0 +1,322 @@ +# Standard Test Pool and Helpers + +Shared fixtures, protocol message builders, and synced-channel setup pattern for all LiveObjects test files. + +## Standard Test Tree + +The standard test pool defines a fixed LiveObjects tree used across test files. All object IDs use short synthetic values for clarity (real servers validate the hash format, but unit tests construct objects directly). + +``` +root (LiveMap, objectId: "root", semantics: LWW) + +-- "name" -> string "Alice" + +-- "age" -> number 30 + +-- "active" -> boolean true + +-- "score" -> objectId "counter:score@1000" + +-- "profile" -> objectId "map:profile@1000" + +-- "data" -> json {"tags": ["a", "b"]} + +-- "avatar" -> bytes base64("AQID") (raw bytes: [1, 2, 3]) + +counter:score@1000 (LiveCounter, data: 100) + +map:profile@1000 (LiveMap, semantics: LWW) + +-- "email" -> string "alice@example.com" + +-- "nested_counter" -> objectId "counter:nested@1000" + +-- "prefs" -> objectId "map:prefs@1000" + +counter:nested@1000 (LiveCounter, data: 5) + +map:prefs@1000 (LiveMap, semantics: LWW) + +-- "theme" -> string "dark" +``` + +All map entries have timeserial `"t:0"` and `tombstone: false` unless otherwise noted. +All objects have `siteTimeserials: { "aaa": "t:0" }` and `createOperationIsMerged: true` unless otherwise noted. + +--- + +## STANDARD_POOL_OBJECTS + +An array of `ObjectMessage` instances wrapping `ObjectState` for building OBJECT_SYNC messages. Each object is represented as `build_object_state(...)` using the builders below. + +```pseudo +STANDARD_POOL_OBJECTS = [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "t:0" }, + "age": { data: { number: 30 }, timeserial: "t:0" }, + "active": { data: { boolean: true }, timeserial: "t:0" }, + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" }, + "data": { data: { json: {"tags": ["a", "b"]} }, timeserial: "t:0" }, + "avatar": { data: { bytes: "AQID" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:score@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }), + build_object_state("map:profile@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "email": { data: { string: "alice@example.com" }, timeserial: "t:0" }, + "nested_counter": { data: { objectId: "counter:nested@1000" }, timeserial: "t:0" }, + "prefs": { data: { objectId: "map:prefs@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:nested@1000", {"aaa": "t:0"}, { + counter: { count: 5 }, + createOp: { counterCreate: { count: 5 } } + }), + build_object_state("map:prefs@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "theme": { data: { string: "dark" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +] +``` + +--- + +## Builder Functions + +### Protocol Message Builders + +```pseudo +build_object_sync_message(channel, channelSerial, objectMessages[]): + RETURN ProtocolMessage( + action: OBJECT_SYNC, + channel: channel, + channelSerial: channelSerial, + state: objectMessages + ) + +build_object_message(channel, objectMessages[]): + RETURN ProtocolMessage( + action: OBJECT, + channel: channel, + state: objectMessages + ) + +build_ack_message(msgSerial, serials[]): + RETURN ProtocolMessage( + action: ACK, + msgSerial: msgSerial, + res: [{ serials: serials }] + ) +``` + +### ObjectMessage Builders (Operations) + +```pseudo +build_counter_inc(objectId, number, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "COUNTER_INC", + objectId: objectId, + counterInc: { number: number } + } + ) + +build_map_set(objectId, key, value, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_SET", + objectId: objectId, + mapSet: { key: key, value: value } + } + ) + +build_map_remove(objectId, key, serial, siteCode, serialTimestamp?): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + operation: { + action: "MAP_REMOVE", + objectId: objectId, + mapRemove: { key: key } + } + ) + +build_map_clear(objectId, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_CLEAR", + objectId: objectId + } + ) + +build_object_delete(objectId, serial, siteCode, serialTimestamp?): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + operation: { + action: "OBJECT_DELETE", + objectId: objectId + } + ) + +build_counter_create(objectId, counterCreate, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "COUNTER_CREATE", + objectId: objectId, + counterCreate: counterCreate + } + ) + +build_map_create(objectId, mapCreate, serial, siteCode): + RETURN ObjectMessage( + serial: serial, + siteCode: siteCode, + operation: { + action: "MAP_CREATE", + objectId: objectId, + mapCreate: mapCreate + } + ) +``` + +### ObjectMessage Builder (State — for OBJECT_SYNC) + +```pseudo +build_object_state(objectId, siteTimeserials, opts): + state = { + objectId: objectId, + siteTimeserials: siteTimeserials + } + IF opts.map IS NOT null: + state.map = opts.map + IF opts.counter IS NOT null: + state.counter = opts.counter + IF opts.tombstone IS NOT null: + state.tombstone = opts.tombstone + IF opts.createOp IS NOT null: + state.createOp = opts.createOp + RETURN ObjectMessage(object: state) +``` + +--- + +## Standard Synced-Channel Setup + +Used by all mock WebSocket test files. Creates a connected client with a synced channel containing the standard test pool. + +```pseudo +setup_synced_channel(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + ELSE IF msg.action == OBJECT: + serials = [] + FOR i IN 0..msg.state.length - 1: + serials.append("ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } + ) + install_mock(mock_ws) + + client = Realtime(options: { + key: "fake:key", + autoConnect: true + }) + channel = client.channels.get(channel_name, { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] + }) + root = AWAIT channel.object.get() + + RETURN { client, channel, root, mock_ws } +``` + +### Variant: Setup Without Auto-ACK + +For tests that need to control ACK timing, use this variant that omits the OBJECT message handler: + +```pseudo +setup_synced_channel_no_ack(channel_name): + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message( + msg.channel, "sync1:", STANDARD_POOL_OBJECTS + )) + } + ) + install_mock(mock_ws) + + client = Realtime(options: { + key: "fake:key", + autoConnect: true + }) + channel = client.channels.get(channel_name, { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] + }) + root = AWAIT channel.object.get() + + RETURN { client, channel, root, mock_ws } +``` + +--- + +## REST Fixture Provisioning + +For integration tests that need pre-existing object state before the test client connects, use the REST API to establish fixtures. + +```pseudo +provision_objects_via_rest(api_key, channel_name, operations): + POST https://sandbox-rest.ably.io/channels/{encode_uri_component(channel_name)}/objects + WITH Authorization: Basic {base64(api_key)} + WITH Content-Type: application/json + WITH body: { "messages": operations } +``` diff --git a/uts/objects/integration/objects_batch_test.md b/uts/objects/integration/objects_batch_test.md new file mode 100644 index 000000000..a5805482a --- /dev/null +++ b/uts/objects/integration/objects_batch_test.md @@ -0,0 +1,201 @@ +# Objects Batch Integration Tests + +Spec points: `RTPO22`, `RTBC12`–`RTBC15` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Batch operations end-to-end — multiple mutations in a single publish, atomic +propagation to subscribers. Verifies that batch() groups multiple operations +into a single ProtocolMessage and the server processes and delivers them +correctly to other clients. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name + +--- + +## RTPO22 - Batch set of multiple keys arrives to second client + +**Test ID**: `objects/integration/RTPO22/batch-set-propagates-0` + +**Spec requirement:** batch() groups multiple mutations into a single publish. +All operations are delivered together to subscribers. + +### Setup +```pseudo +channel_name = "objects-batch-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.batch((ctx) => { + ctx.set("x", 1) + ctx.set("y", 2) + ctx.set("z", 3) +}) + +poll_until(root_b.get("x").value() == 1, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("x").value() == 1 +ASSERT root_b.get("y").value() == 2 +ASSERT root_b.get("z").value() == 3 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO22 - Batch with mixed operations (set + remove + increment) + +**Test ID**: `objects/integration/RTPO22/batch-mixed-ops-0` + +**Spec requirement:** Batch can contain different operation types published atomically. + +### Setup +```pseudo +channel_name = "objects-batch-mixed-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Set up initial state +AWAIT root_a.set("to_remove", "temp") +AWAIT root_a.set("counter", LiveCounter.create(10)) +poll_until(root_b.get("to_remove").value() == "temp", timeout: 10s) +poll_until(root_b.get("counter").value() == 10, timeout: 10s) + +// Batch with mixed operations +AWAIT root_a.batch((ctx) => { + ctx.set("name", "Alice") + ctx.remove("to_remove") + child = ctx.get("counter") + child.increment(5) +}) + +poll_until(root_b.get("name").value() == "Alice", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("name").value() == "Alice" +ASSERT root_b.get("to_remove").value() == null +ASSERT root_b.get("counter").value() == 15 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO22 - Batch with LiveCounterValueType creates counter atomically + +**Test ID**: `objects/integration/RTPO22/batch-create-counter-0` + +**Spec requirement:** Batch containing LiveCounterValueType generates COUNTER_CREATE + +MAP_SET in a single publish. The server processes both atomically. + +### Setup +```pseudo +channel_name = "objects-batch-counter-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.batch((ctx) => { + ctx.set("batch_counter", LiveCounter.create(99)) + ctx.set("label", "created in batch") +}) + +poll_until(root_b.get("batch_counter").value() == 99, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("batch_counter").value() == 99 +ASSERT root_b.get("label").value() == "created in batch" +ASSERT root_b.get("batch_counter").instance() IS NOT null +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` diff --git a/uts/objects/integration/objects_gc_test.md b/uts/objects/integration/objects_gc_test.md new file mode 100644 index 000000000..2d9bc86a2 --- /dev/null +++ b/uts/objects/integration/objects_gc_test.md @@ -0,0 +1,138 @@ +# Objects GC Integration Tests + +Spec points: `RTO10`, `RTLM19` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Behavioral verification of garbage collection for tombstoned objects and tombstoned +map entries. Uses `ADVANCE_TIME` (fake timers) to control timing and verifies GC +through observable API consequences rather than internal pool state inspection. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- These tests use fake timers to control GC timing +- Each test uses a unique channel name + +--- + +## RTO10 - Tombstoned object is GC'd and recreatable + +**Test ID**: `objects/integration/RTO10/tombstoned-object-gc-recreate-0` + +**Spec requirement:** After an object is tombstoned and the GC grace period elapses, +the object is removed from the pool. A new object can then be created at the same +map key. + +### Setup +```pseudo +enable_fake_timers() +channel_name = "objects-gc-object-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Create a counter +AWAIT root.set("counter", LiveCounter.create(42)) +ASSERT root.get("counter").value() == 42 +counter_id = root.get("counter").instance().id() + +// Remove it (tombstones the entry and the object) +AWAIT root.remove("counter") +ASSERT root.get("counter").value() == null + +// Advance past GC grace period +ADVANCE_TIME(86400000 + 300000) + +// Create a new counter at the same key +AWAIT root.set("counter", LiveCounter.create(99)) +``` + +### Assertions +```pseudo +ASSERT root.get("counter").value() == 99 +new_counter_id = root.get("counter").instance().id() +ASSERT new_counter_id != counter_id +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTLM19 - Tombstoned map entry is GC'd, re-settable with old serial + +**Test ID**: `objects/integration/RTLM19/tombstoned-entry-gc-reset-0` + +**Spec requirement:** After a map entry is tombstoned and GC'd, the entry is fully +removed. A subsequent MAP_SET with any serial succeeds because there is no existing +entry to compare against. + +### Setup +```pseudo +enable_fake_timers() +channel_name = "objects-gc-entry-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Set then remove a key +AWAIT root.set("ephemeral", "temporary") +ASSERT root.get("ephemeral").value() == "temporary" + +AWAIT root.remove("ephemeral") +ASSERT root.get("ephemeral").value() == null + +// Advance past GC grace period for entries +ADVANCE_TIME(86400000 + 300000) + +// Set the same key again +AWAIT root.set("ephemeral", "revived") +``` + +### Assertions +```pseudo +ASSERT root.get("ephemeral").value() == "revived" +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/objects_lifecycle_test.md b/uts/objects/integration/objects_lifecycle_test.md new file mode 100644 index 000000000..9c440f512 --- /dev/null +++ b/uts/objects/integration/objects_lifecycle_test.md @@ -0,0 +1,317 @@ +# Objects Lifecycle Integration Tests + +Spec points: `RTO23`, `RTPO15`, `RTPO17` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end lifecycle: connect, sync, create objects via PathObject, mutate, and +verify propagation to a second client. Complements unit tests by verifying real +server sync, mutation delivery, and object creation. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name to avoid interference + +--- + +## RTO23, RTPO15 - Set primitive via PathObject, second client reads it + +**Test ID**: `objects/integration/RTO23-RTPO15/set-primitive-propagates-0` + +**Spec requirement:** PathObject#set delegates to LiveMap#set. The mutation +propagates via the server and a second client sees the updated value. + +### Setup +```pseudo +channel_name = "objects-lifecycle-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Client A sets a value +AWAIT root_a.set("greeting", "hello") + +// Client B subscribes and waits for the update +events_b = [] +root_b.subscribe((event) => events_b.append(event)) +poll_until(root_b.get("greeting").value() == "hello", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("greeting").value() == "hello" +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO15 - Set with LiveCounterValueType, second client reads counter + +**Test ID**: `objects/integration/RTPO15/set-counter-value-type-0` + +**Spec requirement:** PathObject#set with LiveCounterValueType creates a new counter +on the server. Second client syncs and reads the counter value. + +### Setup +```pseudo +channel_name = "objects-counter-create-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.set("my_counter", LiveCounter.create(42)) +poll_until(root_b.get("my_counter").value() == 42, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("my_counter").value() == 42 +ASSERT root_b.get("my_counter").instance() IS NOT null +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO17 - Increment counter, second client sees updated value + +**Test ID**: `objects/integration/RTPO17/increment-propagates-0` + +**Spec requirement:** PathObject#increment delegates to LiveCounter#increment. +The server applies the increment and propagates the updated value. + +### Setup +```pseudo +channel_name = "objects-increment-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +// Create a counter first +AWAIT root_a.set("hits", LiveCounter.create(0)) +poll_until(root_b.get("hits").value() == 0, timeout: 10s) + +// Increment it +AWAIT root_a.get("hits").increment(10) +poll_until(root_b.get("hits").value() == 10, timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_a.get("hits").value() == 10 +ASSERT root_b.get("hits").value() == 10 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTPO15 - Set with LiveMapValueType, second client reads nested map + +**Test ID**: `objects/integration/RTPO15/set-map-value-type-0` + +**Spec requirement:** PathObject#set with LiveMapValueType creates a nested map. +Second client can navigate into the nested map. + +### Setup +```pseudo +channel_name = "objects-map-create-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +root_a = AWAIT channel_a.object.get() +root_b = AWAIT channel_b.object.get() +``` + +### Test Steps +```pseudo +AWAIT root_a.set("settings", LiveMap.create({ + "theme": "dark", + "fontSize": 14 +})) +poll_until(root_b.get("settings").get("theme").value() == "dark", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("settings").get("theme").value() == "dark" +ASSERT root_b.get("settings").get("fontSize").value() == 14 +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTO23 - get() waits for sync and returns PathObject + +**Test ID**: `objects/integration/RTO23/get-returns-path-object-0` + +**Spec requirement:** channel.object.get() returns a PathObject pointing to the root +after the sync sequence completes. + +### Setup +```pseudo +channel_name = "objects-get-root-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +ASSERT root.size() == 0 +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTPO15 - Client syncs pre-existing data provisioned via REST + +**Test ID**: `objects/integration/RTPO15/rest-provisioned-data-sync-0` + +**Spec requirement:** Data created via the REST API is visible to a realtime client +that connects afterward. + +### Setup +```pseudo +channel_name = "objects-rest-provision-" + random_id() + +// Provision data via REST before any realtime client connects +provision_objects_via_rest(api_key, channel_name, [ + { + operation: { + action: "MAP_SET", + objectId: "root", + mapSet: { key: "provisioned", value: { string: "from_rest" } } + } + } +]) +``` + +### Test Steps +```pseudo +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root.get("provisioned").value() == "from_rest" +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/objects_sync_test.md b/uts/objects/integration/objects_sync_test.md new file mode 100644 index 000000000..7f0721ec2 --- /dev/null +++ b/uts/objects/integration/objects_sync_test.md @@ -0,0 +1,200 @@ +# Objects Sync Integration Tests + +Spec points: `RTO4`, `RTO5`, `RTO17` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +Verify the sync sequence against the real server: attach with HAS_OBJECTS, +receive OBJECT_SYNC, reach SYNCED state. Also tests re-attach behaviour where +the client detaches and re-attaches to verify the pool is re-synced. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- Each test uses a unique channel name + +--- + +## RTO4, RTO5 - Attach triggers sync, get() resolves after SYNCED + +**Test ID**: `objects/integration/RTO4-RTO5/attach-sync-get-0` + +**Spec requirement:** On ATTACHED with HAS_OBJECTS flag, client transitions to SYNCING, +processes OBJECT_SYNC messages, then transitions to SYNCED. get() waits for SYNCED. + +### Setup +```pseudo +channel_name = "objects-sync-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTO5, RTO17 - Two clients sync same channel with pre-existing data + +**Test ID**: `objects/integration/RTO5-RTO17/two-clients-sync-0` + +**Spec requirement:** Both clients complete sync and see the same object pool state. + +### Setup +```pseudo +channel_name = "objects-two-sync-" + random_id() + +client_a = Realtime(options: { key: api_key }) +client_b = Realtime(options: { key: api_key }) + +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +// Client A creates data +root_a = AWAIT channel_a.object.get() +AWAIT root_a.set("key1", "value1") + +// Client B attaches and syncs — should see the data +root_b = AWAIT channel_b.object.get() +poll_until(root_b.get("key1").value() == "value1", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root_b.get("key1").value() == "value1" +``` + +### Teardown +```pseudo +client_a.close() +client_b.close() +``` + +--- + +## RTO17 - Re-attach re-syncs object pool + +**Test ID**: `objects/integration/RTO17/reattach-resyncs-0` + +**Spec requirement:** On re-attach, the sync state machine restarts and the pool +is re-populated from the server. + +### Setup +```pseudo +channel_name = "objects-reattach-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +// Set some data +AWAIT root.set("before_detach", "hello") +ASSERT root.get("before_detach").value() == "hello" + +// Detach and re-attach +AWAIT channel.detach() +AWAIT channel.attach() + +// Re-sync should restore data +root = AWAIT channel.object.get() +poll_until(root.get("before_detach").value() == "hello", timeout: 10s) +``` + +### Assertions +```pseudo +ASSERT root.get("before_detach").value() == "hello" +``` + +### Teardown +```pseudo +client.close() +``` + +--- + +## RTO4 - Attach without OBJECT_SUBSCRIBE still resolves get() with empty pool + +**Test ID**: `objects/integration/RTO4/attach-subscribe-only-0` + +**Spec requirement:** Channel attached with only OBJECT_SUBSCRIBE mode. Server +sends HAS_OBJECTS, sync completes, root is an empty LiveMap. + +### Setup +```pseudo +channel_name = "objects-subscribe-only-" + random_id() + +client = Realtime(options: { key: api_key }) +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.size() == 0 +``` + +### Teardown +```pseudo +client.close() +``` diff --git a/uts/objects/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md new file mode 100644 index 000000000..24069b737 --- /dev/null +++ b/uts/objects/integration/proxy/objects_faults.md @@ -0,0 +1,459 @@ +# Objects Proxy Integration Tests + +Spec points: `RTO5a2`, `RTO7`, `RTO8`, `RTO17`, `RTO20e` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `objects/unit/objects_pool.md` — RTO5a2 (new sync discards old), RTO7/RTO8 (buffering during SYNCING) +- `objects/unit/realtime_object.md` — RTO17 (sync state events), RTO20e (publishAndApply waits for SYNCED/fails on FAILED) + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds + IF session IS NOT null: + session.close() +``` + +### Protocol Message Action Numbers (Objects-relevant) + +| Name | Number | +|------|--------| +| ATTACHED | 11 | +| DETACHED | 13 | +| OBJECT | 19 | +| OBJECT_SYNC | 20 | + +--- + +## RTO5a2, RTO17 - Sync interrupted by disconnect, re-syncs on reconnect + +**Test ID**: `objects/proxy/RTO5a2-RTO17/sync-interrupted-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a2 | New sync sequence discards old SyncObjectsPool | +| RTO17 | Sync state transitions: SYNCING → SYNCED, re-triggered on re-attach | + +Tests that when the connection drops mid-OBJECT_SYNC, the client discards +partial sync state and re-syncs cleanly on reconnect. The proxy disconnects +after the first OBJECT_SYNC frame so the sync is never completed, then on +reconnect the client re-attaches and syncs fully. + +### Setup + +```pseudo +channel_name = "objects-sync-interrupt-" + random_id() + +// Disconnect after first OBJECT_SYNC frame +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "ws_frame_to_client", "action": 20 }, + "action": { "type": "disconnect" }, + "times": 1, + "comment": "RTO5a2: Disconnect after first OBJECT_SYNC to interrupt sync" + }] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +// First attach triggers sync; proxy disconnects mid-sync +channel.attach() +AWAIT_STATE client.connection.state == DISCONNECTED + WITH timeout: 15 seconds + +// Client auto-reconnects; re-attach triggers fresh sync +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 30 seconds + +// get() waits for SYNCED — will only resolve if re-sync completes +root = AWAIT channel.object.get() + WITH timeout: 30 seconds +``` + +### Assertions + +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +--- + +## RTO7, RTO8 - Mutations during re-sync are buffered and applied + +**Test ID**: `objects/proxy/RTO7-RTO8/mutations-buffered-during-resync-0` + +| Spec | Requirement | +|------|-------------| +| RTO7 | Buffer OBJECT messages during SYNCING | +| RTO8 | Apply buffered messages after sync completes | + +Client A publishes mutations while client B is re-syncing after reconnect. +The mutations should be buffered and applied after the sync completes. + +### Setup + +```pseudo +channel_name = "objects-buffer-resync-" + random_id() + +// Client A: direct connection (no proxy), publishes mutations +client_a = Realtime(options: { key: api_key }) +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + WITH timeout: 15 seconds + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root_a = AWAIT channel_a.object.get() + +// Set initial data +AWAIT root_a.set("key1", "initial") + +// Client B: through proxy, will be disconnected +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [] +) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +// Client B connects and syncs +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 15 seconds + +root_b = AWAIT channel_b.object.get() + WITH timeout: 15 seconds +poll_until(root_b.get("key1").value() == "initial", timeout: 10s) + +// Disconnect client B +session.trigger_action({ type: "disconnect" }) +AWAIT_STATE client_b.connection.state == DISCONNECTED + WITH timeout: 15 seconds + +// While B is disconnected, A publishes a mutation +AWAIT root_a.set("key1", "updated_during_disconnect") + +// Client B reconnects and re-syncs; the mutation should be visible +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 30 seconds + +root_b = AWAIT channel_b.object.get() + WITH timeout: 15 seconds +poll_until(root_b.get("key1").value() == "updated_during_disconnect", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root_b.get("key1").value() == "updated_during_disconnect" +``` + +### Teardown + +```pseudo +client_a.close() +client_b.close() +session.close() +``` + +--- + +## RTO17 - Server-initiated detach triggers re-sync on re-attach + +**Test ID**: `objects/proxy/RTO17/server-detach-resync-0` + +| Spec | Requirement | +|------|-------------| +| RTO17 | On re-attach, sync state machine restarts from INITIALIZED | + +The proxy injects a DETACHED message for the channel, simulating a server-initiated +detach. After the client automatically re-attaches, it must re-sync the object pool. + +### Setup + +```pseudo +channel_name = "objects-detach-resync-" + random_id() + +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +root = AWAIT channel.object.get() + WITH timeout: 15 seconds + +// Set some data +AWAIT root.set("before_detach", "hello") +ASSERT root.get("before_detach").value() == "hello" + +// Inject server-initiated DETACHED +session.trigger_action({ + type: "inject_to_client", + message: { + action: 13, + channel: channel_name + } +}) + +// Client should auto-re-attach (RTL13a) +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 30 seconds + +// Re-sync should restore data +root = AWAIT channel.object.get() + WITH timeout: 15 seconds +poll_until(root.get("before_detach").value() == "hello", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root.get("before_detach").value() == "hello" +``` + +--- + +## RTO20e - publishAndApply fails when channel enters FAILED during SYNCING + +**Test ID**: `objects/proxy/RTO20e/publish-fails-on-channel-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO20e | publishAndApply waits for SYNCED; fails with 92008 if channel enters DETACHED/SUSPENDED/FAILED | + +Client sets up a channel with objects, then the proxy injects a channel ERROR +to transition to FAILED. A PathObject mutation (which uses publishAndApply +internally) should fail with error 92008. + +### Setup + +```pseudo +channel_name = "objects-publish-failed-" + random_id() + +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [] +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + WITH timeout: 15 seconds + +root = AWAIT channel.object.get() + WITH timeout: 15 seconds + +// Inject channel ERROR to transition to FAILED +session.trigger_action({ + type: "inject_to_client", + message: { + action: 9, + channel: channel_name, + error: { statusCode: 400, code: 90000, message: "injected error" } + } +}) + +AWAIT_STATE channel.state == ChannelState.failed + WITH timeout: 15 seconds + +// Attempt a mutation — should fail since channel is FAILED +AWAIT root.set("key", "value") FAILS WITH error +``` + +### Assertions + +```pseudo +ASSERT error.code == 92008 +``` + +--- + +## RTO5, RTO7 - Publish during sync, echo arrives after sync completes + +**Test ID**: `objects/proxy/RTO5-RTO7/publish-during-sync-echo-after-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c6 | Apply buffered OBJECT messages after sync completes | +| RTO7 | Buffer OBJECT messages during SYNCING | + +The proxy delays the OBJECT_SYNC completion so the client stays in SYNCING. +Client A publishes a mutation that arrives as an OBJECT message to client B +while B is still syncing. The mutation must be buffered and applied after +sync completes. + +### Setup + +```pseudo +channel_name = "objects-publish-during-sync-" + random_id() + +// Client A: direct, no proxy +client_a = Realtime(options: { key: api_key }) +client_a.connect() +AWAIT_STATE client_a.connection.state == CONNECTED + WITH timeout: 15 seconds + +channel_a = client_a.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root_a = AWAIT channel_a.object.get() + +// Set up initial data +AWAIT root_a.set("existing", "before") + +// Client B: through proxy with delayed OBJECT_SYNC +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "ws_frame_to_client", "action": 20 }, + "action": { "type": "delay", "delayMs": 3000 }, + "times": 1, + "comment": "Delay first OBJECT_SYNC to keep B in SYNCING state" + }] +) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_b = client_b.channels.get(channel_name, { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps + +```pseudo +// Start client B — will be stuck in SYNCING due to delayed OBJECT_SYNC +client_b.connect() +AWAIT_STATE client_b.connection.state == CONNECTED + WITH timeout: 15 seconds +channel_b.attach() + +// While B is syncing, A publishes a mutation +AWAIT root_a.set("existing", "after") + +// B's get() will resolve once delayed sync completes +root_b = AWAIT channel_b.object.get() + WITH timeout: 30 seconds + +// The mutation from A should be visible (either in sync data or buffered OBJECT) +poll_until(root_b.get("existing").value() == "after", timeout: 15s) +``` + +### Assertions + +```pseudo +ASSERT root_b.get("existing").value() == "after" +``` + +### Teardown + +```pseudo +client_a.close() +client_b.close() +session.close() +``` diff --git a/uts/objects/unit/batch.md b/uts/objects/unit/batch.md new file mode 100644 index 000000000..b53098c35 --- /dev/null +++ b/uts/objects/unit/batch.md @@ -0,0 +1,782 @@ +# Batch API Tests + +Spec points: `RTPO22`, `RTINS19`, `RTBC1`–`RTBC16` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO22 - PathObject#batch resolves path and executes fn + +**Test ID**: `objects/unit/RTPO22/batch-resolves-and-executes-0` + +| Spec | Requirement | +|------|-------------| +| RTPO22c | Resolves path to LiveObject | +| RTPO22d | Creates RootBatchContext wrapping Instance | +| RTPO22e | Executes fn with BatchContext | +| RTPO22f | Flushes after fn returns | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") + ctx.set("age", 31) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 2 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[0].operation.mapSet.key == "name" +ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.mapSet.key == "age" +``` + +--- + +## RTPO22c - PathObject#batch on unresolvable path throws 92007 + +**Test ID**: `objects/unit/RTPO22c/batch-unresolvable-throws-0` + +**Spec requirement:** If path does not resolve to LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").batch((ctx) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS19 - Instance#batch resolves and executes fn + +**Test ID**: `objects/unit/RTINS19/batch-instance-executes-0` + +| Spec | Requirement | +|------|-------------| +| RTINS19d | Creates RootBatchContext wrapping Instance | +| RTINS19e | Executes fn with BatchContext | +| RTINS19f | Flushes after fn returns | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +instance = root.instance() +AWAIT instance.batch((ctx) => { + ctx.set("name", "Charlie") + ctx.remove("age") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 2 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.action == "MAP_REMOVE" +``` + +--- + +## RTINS19c - Instance#batch on non-LiveObject throws 92007 + +**Test ID**: `objects/unit/RTINS19c/batch-non-live-object-throws-0` + +**Spec requirement:** If wrapped value is not a LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +name_inst = root.instance().get("name") +AWAIT name_inst.batch((ctx) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC3 - BatchContext#id returns objectId + +**Test ID**: `objects/unit/RTBC3/id-returns-objectid-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_id = null +AWAIT root.batch((ctx) => { + received_id = ctx.id() +}) +``` + +### Assertions +```pseudo +ASSERT received_id == "root" +``` + +--- + +## RTBC5 - BatchContext#value delegates to Instance#value + +**Test ID**: `objects/unit/RTBC5/value-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_value = null +AWAIT root.get("score").batch((ctx) => { + received_value = ctx.value() +}) +``` + +### Assertions +```pseudo +ASSERT received_value == 100 +``` + +--- + +## RTBC4 - BatchContext#get wraps result via wrapInstance + +**Test ID**: `objects/unit/RTBC4/get-wraps-instance-0` + +| Spec | Requirement | +|------|-------------| +| RTBC4c | Delegates to Instance#get | +| RTBC4d | Wraps result via RootBatchContext#wrapInstance | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +child_id = null +AWAIT root.batch((ctx) => { + child = ctx.get("score") + child_id = child.id() +}) +``` + +### Assertions +```pseudo +ASSERT child_id == "counter:score@1000" +``` + +--- + +## RTBC4 - BatchContext#get returns null for nonexistent key + +**Test ID**: `objects/unit/RTBC4/get-null-nonexistent-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = "not_null" +AWAIT root.batch((ctx) => { + result = ctx.get("nonexistent") +}) +``` + +### Assertions +```pseudo +ASSERT result == null +``` + +--- + +## RTBC6 - BatchContext#entries yields [key, BatchContext] pairs + +**Test ID**: `objects/unit/RTBC6/entries-yields-pairs-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = [] +AWAIT root.batch((ctx) => { + FOR [key, child] IN ctx.entries(): + keys.append(key) +}) +``` + +### Assertions +```pseudo +ASSERT keys.length == 6 +ASSERT "name" IN keys +ASSERT "score" IN keys +``` + +--- + +## RTBC9 - BatchContext#size delegates to Instance#size + +**Test ID**: `objects/unit/RTBC9/size-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +received_size = null +AWAIT root.batch((ctx) => { + received_size = ctx.size() +}) +``` + +### Assertions +```pseudo +ASSERT received_size == 6 +``` + +--- + +## RTBC10 - BatchContext#compact delegates to Instance#compact + +**Test ID**: `objects/unit/RTBC10/compact-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = null +AWAIT root.batch((ctx) => { + result = ctx.compact() +}) +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["score"] == 100 +``` + +--- + +## RTBC12 - BatchContext#set queues MAP_SET message + +**Test ID**: `objects/unit/RTBC12/set-queues-map-set-0` + +| Spec | Requirement | +|------|-------------| +| RTBC12d | Queues message constructor for MAP_SET | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_SET" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapSet.key == "name" +ASSERT obj_msg.operation.mapSet.value.string == "Bob" +``` + +--- + +## RTBC12c - BatchContext#set on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTBC12c/set-non-map-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.set("key", "value") +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC13 - BatchContext#remove queues MAP_REMOVE message + +**Test ID**: `objects/unit/RTBC13/remove-queues-map-remove-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.remove("name") +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" +``` + +--- + +## RTBC14 - BatchContext#increment queues COUNTER_INC message + +**Test ID**: `objects/unit/RTBC14/increment-queues-counter-inc-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.increment(25) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.objectId == "counter:score@1000" +ASSERT obj_msg.operation.counterInc.number == 25 +``` + +--- + +## RTBC14c - BatchContext#increment on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTBC14c/increment-non-counter-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.increment(5) +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTBC15 - BatchContext#decrement delegates to increment with negated amount + +**Test ID**: `objects/unit/RTBC15/decrement-negates-0` + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.get("score").batch((ctx) => { + ctx.decrement(10) +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.counterInc.number == -10 +``` + +--- + +## RTBC16c - wrapInstance memoizes by objectId + +**Test ID**: `objects/unit/RTBC16c/wrap-instance-memoized-0` + +**Spec requirement:** If a wrapper for that objectId already exists, the existing wrapper is returned. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +same_ref = false +AWAIT root.batch((ctx) => { + child1 = ctx.get("score") + child2 = ctx.get("score") + same_ref = (child1 IS child2) +}) +``` + +### Assertions +```pseudo +ASSERT same_ref == true +``` + +--- + +## RTBC16d - flush publishes via RTO15 (publish, not publishAndApply) + +**Test ID**: `objects/unit/RTBC16d/flush-uses-publish-0` + +**Spec requirement:** Flushes queued messages as a single array via RealtimeObject#publish. + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") + ctx.set("age", 31) + child = ctx.get("score") + child.increment(50) +}) +``` + +### Assertions +```pseudo +// All operations published as a single OBJECT message +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].state.length == 3 +ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" +ASSERT captured_messages[0].state[2].operation.action == "COUNTER_INC" +``` + +--- + +## RTBC16d - flush with no queued messages does not publish + +**Test ID**: `objects/unit/RTBC16d/flush-empty-no-publish-0` + +**Spec requirement:** If there are no queued messages, no publish is performed. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTPO22, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + // Read-only: no writes queued + ctx.value() + ctx.size() +}) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 0 +``` + +--- + +## RTBC16e - closed batch throws 40000 on any method call + +**Test ID**: `objects/unit/RTBC16e/closed-batch-throws-0` + +**Spec requirement:** After the batch is closed, any method call must throw 40000. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx +}) + +saved_ctx.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTBC16e - closed batch read methods also throw 40000 + +**Test ID**: `objects/unit/RTBC16e/closed-batch-read-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx +}) + +saved_ctx.id() FAILS WITH error_id +saved_ctx.value() FAILS WITH error_value +saved_ctx.size() FAILS WITH error_size +``` + +### Assertions +```pseudo +ASSERT error_id.code == 40000 +ASSERT error_value.code == 40000 +ASSERT error_size.code == 40000 +``` + +--- + +## RTPO22g - RootBatchContext closed after flush regardless of success + +**Test ID**: `objects/unit/RTPO22g/closed-after-flush-0` + +**Spec requirement:** The RootBatchContext is closed after flush completes, regardless of success or failure. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +saved_ctx = null +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + saved_ctx = ctx + ctx.set("name", "Bob") +}) + +saved_ctx.set("age", 99) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTPO22b - PathObject#batch requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTPO22b/batch-requires-publish-mode-0` + +**Spec requirement:** Requires OBJECT_PUBLISH channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.batch((ctx) => { + ctx.set("name", "Bob") +}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` diff --git a/uts/objects/unit/instance.md b/uts/objects/unit/instance.md new file mode 100644 index 000000000..221d635e7 --- /dev/null +++ b/uts/objects/unit/instance.md @@ -0,0 +1,524 @@ +# Instance Tests + +Spec points: `RTINS1`–`RTINS19` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTINS3 - id property returns objectId + +**Test ID**: `objects/unit/RTINS3/id-returns-objectid-0` + +| Spec | Requirement | +|------|-------------| +| RTINS3a | LiveObject -> returns objectId | +| RTINS3b | Primitive -> returns null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst.id() == "counter:score@1000" + +map_inst = root.get("profile").instance() +ASSERT map_inst.id() == "map:profile@1000" +``` + +--- + +## RTINS4 - value() returns counter number or primitive + +**Test ID**: `objects/unit/RTINS4/value-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTINS4a | LiveCounter -> numeric value | +| RTINS4c | LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst.value() == 100 + +map_inst = root.instance() +ASSERT map_inst.value() == null +``` + +--- + +## RTINS5 - get() returns Instance wrapping entry value + +**Test ID**: `objects/unit/RTINS5/get-wraps-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTINS5b | LiveMap -> look up key, wrap result in Instance | +| RTINS5c | Non-LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Assertions +```pseudo +name_inst = root_inst.get("name") +ASSERT name_inst IS Instance +ASSERT name_inst.value() == "Alice" + +score_inst = root_inst.get("score") +ASSERT score_inst.id() == "counter:score@1000" + +null_inst = root_inst.get("nonexistent") +ASSERT null_inst == null +``` + +--- + +## RTINS6 - entries() yields [key, Instance] pairs + +**Test ID**: `objects/unit/RTINS6/entries-yields-instances-0` + +| Spec | Requirement | +|------|-------------| +| RTINS6a | LiveMap -> [key, Instance] pairs | +| RTINS6b | Non-LiveMap -> empty iterator | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +entries = {} +FOR [key, inst] IN root_inst.entries(): + entries[key] = inst +``` + +### Assertions +```pseudo +ASSERT entries.length == 7 +ASSERT entries["name"] IS Instance +ASSERT entries["name"].value() == "Alice" +``` + +--- + +## RTINS9 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTINS9/size-0` + +| Spec | Requirement | +|------|-------------| +| RTINS9a | LiveMap -> non-tombstoned entry count | +| RTINS9b | Non-LiveMap -> null | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +root_inst = root.instance() +ASSERT root_inst.size() == 7 + +counter_inst = root.get("score").instance() +ASSERT counter_inst.size() == null +``` + +--- + +## RTINS10 - compact() recursively compacts + +**Test ID**: `objects/unit/RTINS10/compact-0` + +**Spec requirement:** Behaves identically to PathObject#compact on the wrapped value. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +result = root_inst.compact() +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["score"] == 100 +ASSERT result["profile"]["email"] == "alice@example.com" +``` + +--- + +## RTINS12 - set() delegates to LiveMap#set + +**Test ID**: `objects/unit/RTINS12/set-delegates-0` + +| Spec | Requirement | +|------|-------------| +| RTINS12b | LiveMap -> delegate to LiveMap#set | +| RTINS12c | Non-LiveMap -> throw 92007 | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT root_inst.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTINS12c - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTINS12c/set-non-map-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS13 - remove() delegates to LiveMap#remove + +**Test ID**: `objects/unit/RTINS13/remove-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT root_inst.remove("name") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == null +``` + +--- + +## RTINS14 - increment() delegates to LiveCounter#increment + +**Test ID**: `objects/unit/RTINS14/increment-delegates-0` + +| Spec | Requirement | +|------|-------------| +| RTINS14b | LiveCounter -> delegate to increment | +| RTINS14c | Non-LiveCounter -> throw 92007 | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment(25) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 125 +``` + +--- + +## RTINS14c - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTINS14c/increment-non-counter-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +map_inst = root.instance() +``` + +### Test Steps +```pseudo +AWAIT map_inst.increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS15 - decrement() delegates to LiveCounter#decrement + +**Test ID**: `objects/unit/RTINS15/decrement-delegates-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTINS16 - subscribe() receives InstanceSubscriptionEvent + +**Test ID**: `objects/unit/RTINS16/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16c | Subscribes via LiveObject#subscribe | +| RTINS16d1 | Event.object is the Instance | +| RTINS16e | Returns Subscription | +| RTINS16f | Identity-based subscription | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +sub = counter_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT events.length == 1 +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "counter:score@1000" +``` + +--- + +## RTINS16b - subscribe() on primitive throws 92007 + +**Test ID**: `objects/unit/RTINS16b/subscribe-primitive-throws-0` + +**Spec requirement:** If wrapped value is not LiveObject, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +name_inst = root.instance().get("name") +name_inst.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTINS16f - Instance subscription follows identity not path + +**Test ID**: `objects/unit/RTINS16f/subscription-follows-identity-0` + +**Spec requirement:** Instance follows the specific LiveObject, regardless of tree position. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +counter_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +ASSERT counter_inst.id() == "counter:score@1000" +``` + +--- + +## RTINS17 - unsubscribe() deregisters listener + +**Test ID**: `objects/unit/RTINS17/unsubscribe-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +events = [] +sub = counter_inst.subscribe((event) => events.append(event)) +sub.unsubscribe() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTINS14a - increment() defaults to 1 + +**Test ID**: `objects/unit/RTINS14a/increment-default-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTINS15a - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTINS15a/decrement-default-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + +## RTINS16 - Subscription event contains message metadata + +**Test ID**: `objects/unit/RTINS16/subscription-event-metadata-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16d1 | Event.object is the Instance | +| RTINS16d2 | Event.message is the ObjectMessage that triggered the update | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +root_inst = root.instance() +events = [] +root_inst.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "root" +ASSERT events[0].message IS NOT null +ASSERT events[0].message.operation.action == "MAP_SET" +ASSERT events[0].message.operation.mapSet.key == "name" +``` diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/live_counter.md new file mode 100644 index 000000000..300f1779b --- /dev/null +++ b/uts/objects/unit/live_counter.md @@ -0,0 +1,824 @@ +# LiveCounter Tests + +Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LiveCounter` CRDT data structure. LiveCounter holds a 64-bit float and supports increment operations, create operations (initial value merge), data replacement during sync, tombstoning, and serial-based newness checks. + +Tests operate directly on LiveCounter by calling `applyOperation()` and `replaceData()` with constructed messages. No channel or connection infrastructure is needed. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `build_counter_inc`, `build_counter_create`, `build_object_delete`, `build_object_state`. + +--- + +## RTLC4 - Zero-value LiveCounter + +**Test ID**: `objects/unit/RTLC4/zero-value-0` + +**Spec requirement:** The zero-value LiveCounter has data set to 0, empty siteTimeserials, createOperationIsMerged false, isTombstone false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT counter.objectId == "counter:abc@1000" +ASSERT counter.isTombstone == false +ASSERT counter.tombstonedAt == null +ASSERT counter.createOperationIsMerged == false +ASSERT counter.siteTimeserials == {} +``` + +--- + +## RTLC9 - COUNTER_INC adds number to data + +**Test ID**: `objects/unit/RTLC9/counter-inc-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLC9f | Add `CounterInc.number` to data if it exists | +| RTLC9g | Return LiveCounterUpdate with amount set to the number | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 5 +ASSERT update.noop == false +ASSERT update.update.amount == 5 +``` + +--- + +## RTLC9 - COUNTER_INC with negative number + +**Test ID**: `objects/unit/RTLC9/counter-inc-negative-0` + +**Spec requirement:** COUNTER_INC with a negative number decrements the counter. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", -3, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 7 +ASSERT update.update.amount == -3 +``` + +--- + +## RTLC9 - COUNTER_INC with missing number is noop + +**Test ID**: `objects/unit/RTLC9/counter-inc-missing-number-0` + +**Spec requirement:** If CounterInc.number does not exist, return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", + siteCode: "site1", + operation: { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: {} + } +) +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 10 +ASSERT update.noop == true +``` + +--- + +## RTLC9 - Multiple COUNTER_INC operations accumulate + +**Test ID**: `objects/unit/RTLC9/counter-inc-accumulate-0` + +**Spec requirement:** Multiple increments accumulate additively. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +counter.applyOperation(build_counter_inc("counter:abc@1000", 10, "01", "site1"), source: CHANNEL) +counter.applyOperation(build_counter_inc("counter:abc@1000", 20, "02", "site1"), source: CHANNEL) +counter.applyOperation(build_counter_inc("counter:abc@1000", -5, "01", "site2"), source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 25 +``` + +--- + +## RTLC8, RTLC16 - COUNTER_CREATE merges initial count + +**Test ID**: `objects/unit/RTLC8/counter-create-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTLC8c | Merge initial value via RTLC16 | +| RTLC16a | Add counterCreate.count to data | +| RTLC16b | Set createOperationIsMerged to true | +| RTLC16c | Return LiveCounterUpdate with amount = count | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", { count: 42 }, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 42 +ASSERT counter.createOperationIsMerged == true +ASSERT update.update.amount == 42 +``` + +--- + +## RTLC8 - COUNTER_CREATE noop when already merged + +**Test ID**: `objects/unit/RTLC8/counter-create-already-merged-0` + +**Spec requirement:** If createOperationIsMerged is true, log and return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +counter.createOperationIsMerged = true +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", { count: 99 }, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 42 +ASSERT update.noop == true +``` + +--- + +## RTLC16 - COUNTER_CREATE with missing count is noop + +**Test ID**: `objects/unit/RTLC16/counter-create-no-count-0` + +**Spec requirement:** If counterCreate.count does not exist, return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_create("counter:abc@1000", {}, "01", "site1") +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT counter.createOperationIsMerged == true +ASSERT update.noop == true +``` + +--- + +## RTLO4a - canApplyOperation allows when siteSerial is empty + +**Test ID**: `objects/unit/RTLO4a/apply-empty-site-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4a5 | If siteSerial is null or empty, return true | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result IS NOT false +ASSERT counter.data == 5 +``` + +--- + +## RTLO4a - canApplyOperation rejects stale serial + +**Test ID**: `objects/unit/RTLO4a/reject-stale-serial-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4a6 | Return true only if serial is greater than siteSerial lexicographically | +| RTLC7b | If canApplyOperation returns false, discard and return false | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.siteTimeserials = { "site1": "05" } +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 99, "03", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 10 +``` + +--- + +## RTLO4a - canApplyOperation rejects equal serial + +**Test ID**: `objects/unit/RTLO4a/reject-equal-serial-0` + +**Spec requirement:** Serial must be strictly greater; equal serial is rejected. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.siteTimeserials = { "site1": "05" } +counter.data = 10 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 99, "05", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 10 +``` + +--- + +## RTLO4a - canApplyOperation warns on empty serial or siteCode + +**Test ID**: `objects/unit/RTLO4a/warn-invalid-serial-0` + +**Spec requirement:** Both serial and siteCode must be non-empty strings. Otherwise, log warning and do not apply. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg_no_serial = ObjectMessage( + serial: "", + siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } +) +result1 = counter.applyOperation(msg_no_serial, source: CHANNEL) + +msg_no_site = ObjectMessage( + serial: "01", + siteCode: "", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } +) +result2 = counter.applyOperation(msg_no_site, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT result1 == false +ASSERT result2 == false +``` + +--- + +## RTLC7c - CHANNEL source updates siteTimeserials + +**Test ID**: `objects/unit/RTLC7c/channel-source-updates-serials-0` + +**Spec requirement:** If source is CHANNEL, set siteTimeserials[siteCode] = serial. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.siteTimeserials["site1"] == "01" +``` + +--- + +## RTLC7c - LOCAL source does not update siteTimeserials + +**Test ID**: `objects/unit/RTLC7c/local-source-no-serial-update-0` + +**Spec requirement:** If source is LOCAL, siteTimeserials must not be updated. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +counter.applyOperation(msg, source: LOCAL) +``` + +### Assertions +```pseudo +ASSERT counter.siteTimeserials == {} +ASSERT counter.data == 5 +``` + +--- + +## RTLC7g - applyOperation returns true on success + +**Test ID**: `objects/unit/RTLC7g/apply-returns-true-0` + +**Spec requirement:** Returns a boolean indicating whether the operation was successfully applied. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == true +``` + +--- + +## RTLO4e, RTLO5 - OBJECT_DELETE tombstones counter + +**Test ID**: `objects/unit/RTLO5/object-delete-tombstones-0` + +| Spec | Requirement | +|------|-------------| +| RTLO5b | Tombstone the LiveObject | +| RTLO4e2 | Set isTombstone to true | +| RTLO4e4 | Set data to zero-value | +| RTLC7d4a | Emit LiveCounterUpdate with negated previous value | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000000000) +update = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.isTombstone == true +ASSERT counter.data == 0 +ASSERT counter.tombstonedAt == 1700000000000 +ASSERT update.update.amount == -42 +``` + +--- + +## RTLC7e - Operations on tombstoned counter are rejected + +**Test ID**: `objects/unit/RTLC7e/tombstoned-reject-ops-0` + +**Spec requirement:** If isTombstone is true, the operation cannot be applied. Return false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.isTombstone = true +counter.tombstonedAt = 1700000000000 +``` + +### Test Steps +```pseudo +msg = build_counter_inc("counter:abc@1000", 5, "01", "site1") +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 0 +``` + +--- + +## RTLO6 - tombstonedAt from serialTimestamp + +**Test ID**: `objects/unit/RTLO6/tombstoned-at-from-serial-timestamp-0` + +| Spec | Requirement | +|------|-------------| +| RTLO6a | tombstonedAt equals serialTimestamp if it exists | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1", 1700000050000) +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT counter.tombstonedAt == 1700000050000 +``` + +--- + +## RTLO6 - tombstonedAt from local clock when no serialTimestamp + +**Test ID**: `objects/unit/RTLO6/tombstoned-at-local-clock-0` + +| Spec | Requirement | +|------|-------------| +| RTLO6b | tombstonedAt equals current local time if serialTimestamp not provided | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +before_time = current_time() +``` + +### Test Steps +```pseudo +msg = build_object_delete("counter:abc@1000", "01", "site1") +counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +after_time = current_time() +ASSERT counter.tombstonedAt >= before_time +ASSERT counter.tombstonedAt <= after_time +``` + +--- + +## RTLC7d3 - Unsupported action is discarded + +**Test ID**: `objects/unit/RTLC7d3/unsupported-action-0` + +**Spec requirement:** Log warning, discard without action, return false. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", + siteCode: "site1", + operation: { action: "MAP_SET", objectId: "counter:abc@1000", mapSet: { key: "x", value: { string: "y" } } } +) +result = counter.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT counter.data == 0 +``` + +--- + +## RTLC6 - replaceData sets data from ObjectState + +**Test ID**: `objects/unit/RTLC6/replace-data-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6a | Replace siteTimeserials from ObjectState | +| RTLC6b | Set createOperationIsMerged to false | +| RTLC6c | Set data to counter.count | +| RTLC6h | Return diff as LiveCounterUpdate | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 10 +counter.createOperationIsMerged = true +counter.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site2": "05"}, { + counter: { count: 50 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 50 +ASSERT counter.siteTimeserials == { "site2": "05" } +ASSERT counter.createOperationIsMerged == false +ASSERT update.update.amount == 40 +``` + +--- + +## RTLC6 - replaceData with createOp merges initial value + +**Test ID**: `objects/unit/RTLC6/replace-data-with-create-op-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6c | Set data to counter.count | +| RTLC6d | If createOp present, merge via RTLC16 | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 50 } } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 150 +ASSERT counter.createOperationIsMerged == true +ASSERT update.update.amount == 150 +``` + +--- + +## RTLC6e - replaceData on tombstoned counter is noop + +**Test ID**: `objects/unit/RTLC6e/replace-data-tombstoned-noop-0` + +**Spec requirement:** If isTombstone is true, finish processing. Return noop. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.isTombstone = true +counter.tombstonedAt = 1700000000000 +counter.data = 0 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 999 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT update.noop == true +``` + +--- + +## RTLC6f - replaceData with tombstone flag tombstones counter + +**Test ID**: `objects/unit/RTLC6f/replace-data-tombstone-flag-0` + +| Spec | Requirement | +|------|-------------| +| RTLC6f | If ObjectState.tombstone is true, tombstone the counter | +| RTLC6f1 | Return LiveCounterUpdate with amount = negated previous data | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 30 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 0 }, + tombstone: true +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.isTombstone == true +ASSERT counter.data == 0 +ASSERT update.update.amount == -30 +``` + +--- + +## RTLC6 - replaceData with missing counter.count defaults to 0 + +**Test ID**: `objects/unit/RTLC6/replace-data-missing-count-0` + +**Spec requirement:** Set data to counter.count, or to 0 if it does not exist. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 42 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: {} +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT counter.data == 0 +ASSERT update.update.amount == -42 +``` + +--- + +## RTLC14 - Diff calculation + +**Test ID**: `objects/unit/RTLC14/diff-calculation-0` + +**Spec requirement:** Return LiveCounterUpdate with amount = newData - previousData. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +counter.data = 20 +``` + +### Test Steps +```pseudo +state_msg = build_object_state("counter:abc@1000", {"site1": "01"}, { + counter: { count: 75 } +}) +update = counter.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT update.update.amount == 55 +``` + +--- + +## RTLC8, RTLC16 - COUNTER_CREATE then COUNTER_INC accumulates + +**Test ID**: `objects/unit/RTLC8/create-then-inc-0` + +**Spec requirement:** Create operation merges initial count, then increment adds to it. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +counter.applyOperation( + build_counter_create("counter:abc@1000", { count: 100 }, "01", "site1"), + source: CHANNEL +) +counter.applyOperation( + build_counter_inc("counter:abc@1000", 25, "02", "site1"), + source: CHANNEL +) +``` + +### Assertions +```pseudo +ASSERT counter.data == 125 +ASSERT counter.createOperationIsMerged == true +``` + +--- + +## RTLO3 - LiveObject properties initialized correctly + +**Test ID**: `objects/unit/RTLO3/live-object-init-properties-0` + +| Spec | Requirement | +|------|-------------| +| RTLO3a1 | objectId must be provided in constructor | +| RTLO3b1 | siteTimeserials set to empty map | +| RTLO3c1 | createOperationIsMerged set to false | +| RTLO3d1 | isTombstone set to false | +| RTLO3e1 | tombstonedAt set to null | + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:test@2000") +``` + +### Assertions +```pseudo +ASSERT counter.objectId == "counter:test@2000" +ASSERT counter.siteTimeserials == {} +ASSERT counter.createOperationIsMerged == false +ASSERT counter.isTombstone == false +ASSERT counter.tombstonedAt == null +``` diff --git a/uts/objects/unit/live_counter_api.md b/uts/objects/unit/live_counter_api.md new file mode 100644 index 000000000..2b5e733e9 --- /dev/null +++ b/uts/objects/unit/live_counter_api.md @@ -0,0 +1,343 @@ +# LiveCounter API Tests + +Spec points: `RTLC5`, `RTLC11`–`RTLC13` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLC5 - value() returns current counter data + +**Test ID**: `objects/unit/RTLC5/value-returns-data-0` + +| Spec | Requirement | +|------|-------------| +| RTLC5c | Returns current data value | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter = root.get("score") +ASSERT counter.value() == 100 +``` + +--- + +## RTLC5a - value() requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTLC5a/value-requires-subscribe-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +This is implicitly tested by `setup_synced_channel` which always includes OBJECT_SUBSCRIBE. A negative test would use a channel without OBJECT_SUBSCRIBE and verify the error. + +--- + +## RTLC12 - increment sends v6 COUNTER_INC message + +**Test ID**: `objects/unit/RTLC12/increment-sends-counter-inc-0` + +| Spec | Requirement | +|------|-------------| +| RTLC12e2 | action set to COUNTER_INC | +| RTLC12e3 | objectId set to counter's objectId | +| RTLC12e5 | counterInc.number set to amount | +| RTLC12g | Publishes via publishAndApply | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(25) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "COUNTER_INC" +ASSERT obj_msg.operation.objectId == "counter:score@1000" +ASSERT obj_msg.operation.counterInc.number == 25 +``` + +--- + +## RTLC12 - increment applies locally after ACK + +**Test ID**: `objects/unit/RTLC12/increment-applies-locally-0` + +**Spec requirement:** Via publishAndApply, value reflects change after await. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(50) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 150 +``` + +--- + +## RTLC12b - increment requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` + +**Spec requirement:** Requires OBJECT_PUBLISH channel mode. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTLC12d - increment with echoMessages false throws + +**Test ID**: `objects/unit/RTLC12d/echo-messages-false-0` + +**Spec requirement:** If echoMessages is false, throw 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLC12e1 - increment with non-number throws + +**Test ID**: `objects/unit/RTLC12e1/increment-non-number-0` + +**Spec requirement:** If amount is null, not Number, not finite, or omitted, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment("not_a_number") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLC13 - decrement delegates to increment with negated amount + +**Test ID**: `objects/unit/RTLC13/decrement-negates-0` + +| Spec | Requirement | +|------|-------------| +| RTLC13b | Alias for increment with negative amount | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.decrement(15) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.counterInc.number == -15 +ASSERT root.get("score").value() == 85 +``` + +--- + +## RTLC11 - LiveCounterUpdate emitted on increment + +**Test ID**: `objects/unit/RTLC11/counter-update-on-inc-0` + +| Spec | Requirement | +|------|-------------| +| RTLC11b1 | update.amount is the increment value | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote-site") +])) + +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates[0].message.operation.counterInc.number == 7 +``` + +--- + +## RTLC12e1 - Table-driven invalid increment amounts + +**Test ID**: `objects/unit/RTLC12e1/increment-invalid-amounts-table-0` + +**Spec requirement:** If amount is null, not Number, not finite, or NaN, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +invalid_amounts = [ + { value: null, label: "null" }, + { value: NaN, label: "NaN" }, + { value: Infinity, label: "Infinity" }, + { value: -Infinity, label: "-Infinity" }, + { value: "10", label: "string" }, + { value: true, label: "boolean" }, + { value: [1, 2], label: "array" }, + { value: { n: 1 }, label: "object" } +] +``` + +### Test Steps +```pseudo +FOR scenario IN invalid_amounts: + AWAIT root.increment(scenario.value) FAILS WITH error + ASSERT error.code == 40003 +``` diff --git a/uts/objects/unit/live_map.md b/uts/objects/unit/live_map.md new file mode 100644 index 000000000..a930c17a3 --- /dev/null +++ b/uts/objects/unit/live_map.md @@ -0,0 +1,980 @@ +# LiveMap Tests + +Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LiveMap` LWW-map CRDT data structure. LiveMap holds a dictionary of `ObjectsMapEntry` values with entry-level last-write-wins semantics, supports set/remove/clear operations, create operations (initial entries merge), data replacement during sync, tombstoning, GC of tombstoned entries, and diff calculation. + +Tests operate directly on LiveMap by calling `applyOperation()` and `replaceData()` with constructed messages. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions. + +--- + +## RTLM4 - Zero-value LiveMap + +**Test ID**: `objects/unit/RTLM4/zero-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM4 | Zero-value LiveMap has empty data map and null clearTimeserial | +| RTLM25 | clearTimeserial initially null | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Assertions +```pseudo +ASSERT map.data == {} +ASSERT map.clearTimeserial == null +ASSERT map.isTombstone == false +ASSERT map.createOperationIsMerged == false +ASSERT map.siteTimeserials == {} +``` + +--- + +## RTLM7 - MAP_SET creates new entry + +**Test ID**: `objects/unit/RTLM7/map-set-new-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7b4 | Create new ObjectsMapEntry with data and timeserial | +| RTLM7f | Return LiveMapUpdate with key set to "updated" | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].timeserial == "01" +ASSERT map.data["name"].tombstone == false +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM7 - MAP_SET updates existing entry + +**Test ID**: `objects/unit/RTLM7/map-set-update-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a2e | Set data to MapSet.value | +| RTLM7a2b | Set timeserial to the provided serial | +| RTLM7a2c | Set tombstone to false | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Bob" } +ASSERT map.data["name"].timeserial == "02" +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM9 - LWW rejects stale serial on existing entry + +**Test ID**: `objects/unit/RTLM9/lww-reject-stale-0` + +| Spec | Requirement | +|------|-------------| +| RTLM9a | Operation serial must be strictly greater than entry serial | +| RTLM9e | Compare lexicographically | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "03", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9 - LWW rejects equal serial + +**Test ID**: `objects/unit/RTLM9/lww-reject-equal-0` + +**Spec requirement:** Equal serials are rejected — must be strictly greater. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "05", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9b - Both serials empty rejects operation + +**Test ID**: `objects/unit/RTLM9b/both-empty-reject-0` + +**Spec requirement:** If both the entry serial and operation serial are null/empty, considered equal, so operation is not applied. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT update.noop == true +``` + +--- + +## RTLM9d - Missing entry serial allows operation + +**Test ID**: `objects/unit/RTLM9d/missing-entry-serial-allows-0` + +**Spec requirement:** If only the operation serial exists and is non-empty, it is greater than the missing entry serial. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: null, tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Bob" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Bob" } +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM7h - MAP_SET rejected when serial <= clearTimeserial + +**Test ID**: `objects/unit/RTLM7h/map-set-clear-timeserial-floor-0` + +**Spec requirement:** If clearTimeserial is non-null and >= serial, discard operation. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "03", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "name" NOT IN map.data +ASSERT update.noop == true +``` + +--- + +## RTLM7g - MAP_SET with objectId creates zero-value object + +**Test ID**: `objects/unit/RTLM7g/map-set-objectid-creates-zero-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7g | If MapSet.value.objectId is non-empty, create zero-value LiveObject | +| RTLM7g1 | Create via RTO6 | + +This test requires an ObjectsPool to be passed alongside the LiveMap. The LiveMap creates a zero-value object in the pool when it encounters an objectId reference. + +### Setup +```pseudo +pool = ObjectsPool() +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "score", { objectId: "counter:new@2000" }, "01", "site1") +map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "counter:new@2000" IN pool +ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"].data == 0 +``` + +--- + +## RTLM8 - MAP_REMOVE tombstones existing entry + +**Test ID**: `objects/unit/RTLM8/map-remove-existing-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8a2a | Set data to null | +| RTLM8a2b | Set timeserial to serial | +| RTLM8a2c | Set tombstone to true | +| RTLM8a2d | Set tombstonedAt via RTLO6 | +| RTLM8e | Return LiveMapUpdate with key set to "removed" | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == null +ASSERT map.data["name"].tombstone == true +ASSERT map.data["name"].timeserial == "02" +ASSERT map.data["name"].tombstonedAt == 1700000000000 +ASSERT update.update == { "name": "removed" } +``` + +--- + +## RTLM8 - MAP_REMOVE creates tombstoned entry if not exists + +**Test ID**: `objects/unit/RTLM8/map-remove-nonexistent-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8b1 | Create new entry with data null and timeserial | +| RTLM8b2 | Set tombstone to true | +| RTLM8b3 | Set tombstonedAt via RTLO6 | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "ghost", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ghost"].tombstone == true +ASSERT map.data["ghost"].tombstonedAt == 1700000000000 +ASSERT update.update == { "ghost": "removed" } +``` + +--- + +## RTLM8g - MAP_REMOVE rejected when serial <= clearTimeserial + +**Test ID**: `objects/unit/RTLM8g/map-remove-clear-timeserial-floor-0` + +**Spec requirement:** If clearTimeserial is non-null and >= serial, discard operation. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +map.data = { + "name": { data: { string: "Alice" }, timeserial: "04", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "03", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].tombstone == false +ASSERT update.noop == true +``` + +--- + +## RTLM24 - MAP_CLEAR sets clearTimeserial and removes older entries + +**Test ID**: `objects/unit/RTLM24/map-clear-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLM24d | Set clearTimeserial to serial | +| RTLM24e1a | Remove entries with timeserial null or < serial | +| RTLM24f | Return LiveMapUpdate with removed keys | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "old": { data: { string: "old" }, timeserial: "02", tombstone: false }, + "new": { data: { string: "new" }, timeserial: "06", tombstone: false }, + "same": { data: { string: "same" }, timeserial: "04", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "04", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == "04" +ASSERT "old" NOT IN map.data +ASSERT "same" NOT IN map.data +ASSERT "new" IN map.data +ASSERT update.update == { "old": "removed", "same": "removed" } +``` + +--- + +## RTLM24c - MAP_CLEAR rejected when clearTimeserial is already greater + +**Test ID**: `objects/unit/RTLM24c/map-clear-stale-0` + +**Spec requirement:** If existing clearTimeserial is greater than provided serial, discard. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "10" +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == "10" +ASSERT update.noop == true +``` + +--- + +## RTLM16, RTLM23 - MAP_CREATE merges entries + +**Test ID**: `objects/unit/RTLM16/map-create-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTLM16d | Merge via RTLM23 | +| RTLM23a1 | Non-tombstoned entries merged via MAP_SET logic | +| RTLM23a2 | Tombstoned entries merged via MAP_REMOVE logic | +| RTLM23b | Set createOperationIsMerged to true | + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_create("map:test@1000", { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "01" }, + "removed_key": { tombstone: true, timeserial: "01", serialTimestamp: 1700000000000 } + } +}, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["removed_key"].tombstone == true +ASSERT map.createOperationIsMerged == true +ASSERT update.update == { "name": "updated", "removed_key": "removed" } +``` + +--- + +## RTLM16b - MAP_CREATE noop when already merged + +**Test ID**: `objects/unit/RTLM16b/map-create-already-merged-0` + +**Spec requirement:** If createOperationIsMerged is true, return noop. + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +map.createOperationIsMerged = true +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_map_create("map:test@1000", { + semantics: "LWW", + entries: { "name": { data: { string: "Bob" }, timeserial: "01" } } +}, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "name" NOT IN map.data +ASSERT update.noop == true +``` + +--- + +## RTLM15c - CHANNEL source updates siteTimeserials + +**Test ID**: `objects/unit/RTLM15c/channel-source-updates-serials-0` + +**Spec requirement:** If source is CHANNEL, set siteTimeserials[siteCode] = serial. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "x", { number: 1 }, "01", "site1") +map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.siteTimeserials["site1"] == "01" +``` + +--- + +## RTLM15e - Operations on tombstoned map are rejected + +**Test ID**: `objects/unit/RTLM15e/tombstoned-reject-ops-0` + +**Spec requirement:** If isTombstone is true, finish without action, return false. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.isTombstone = true +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "x", { number: 1 }, "01", "site1") +result = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +ASSERT map.data == {} +``` + +--- + +## RTLO5 - OBJECT_DELETE tombstones map + +**Test ID**: `objects/unit/RTLO5/object-delete-tombstones-map-0` + +| Spec | Requirement | +|------|-------------| +| RTLM15d5a | Emit LiveMapUpdate with removed keys | +| RTLM15d5b | Return true | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false }, + "age": { data: { number: 30 }, timeserial: "01", tombstone: false } +} +map.siteTimeserials = { "site1": "00" } +``` + +### Test Steps +```pseudo +msg = build_object_delete("root", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +ASSERT update.update == { "name": "removed", "age": "removed" } +``` + +--- + +## RTLM14, RTLM14c - Tombstoned entry check includes objectId reference + +**Test ID**: `objects/unit/RTLM14/tombstone-check-objectid-ref-0` + +| Spec | Requirement | +|------|-------------| +| RTLM14a | Entry is tombstoned if entry.tombstone is true | +| RTLM14c | Entry is tombstoned if referenced LiveObject.isTombstone is true | + +### Setup +```pseudo +pool = ObjectsPool() +tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter.isTombstone = true +pool["counter:dead@1000"] = tombstoned_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false }, + "dead_entry": { data: null, timeserial: "01", tombstone: true }, + "dead_ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } +} +``` + +### Assertions +```pseudo +ASSERT isTombstoned(map.data["alive"]) == false +ASSERT isTombstoned(map.data["dead_entry"]) == true +ASSERT isTombstoned(map.data["dead_ref"]) == true +``` + +--- + +## RTLM6 - replaceData sets data from ObjectState + +**Test ID**: `objects/unit/RTLM6/replace-data-basic-0` + +| Spec | Requirement | +|------|-------------| +| RTLM6a | Replace siteTimeserials | +| RTLM6b | Set createOperationIsMerged to false | +| RTLM6i | Set clearTimeserial from ObjectState.map.clearTimeserial | +| RTLM6c | Set data to ObjectState.map.entries | +| RTLM6h | Return diff LiveMapUpdate | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "old": { data: { string: "old" }, timeserial: "01", tombstone: false } +} +map.createOperationIsMerged = true +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site2": "05"}, { + map: { + semantics: "LWW", + clearTimeserial: "03", + entries: { + "new": { data: { string: "new" }, timeserial: "04", tombstone: false } + } + } +}) +update = map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.siteTimeserials == { "site2": "05" } +ASSERT map.createOperationIsMerged == false +ASSERT map.clearTimeserial == "03" +ASSERT "old" NOT IN map.data +ASSERT map.data["new"].data == { string: "new" } +ASSERT update.update == { "old": "removed", "new": "updated" } +``` + +--- + +## RTLM6c1 - replaceData sets tombstonedAt on tombstoned entries + +**Test ID**: `objects/unit/RTLM6c1/replace-data-tombstoned-entries-0` + +**Spec requirement:** For each tombstoned entry, set tombstonedAt via RTLO6. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "dead": { tombstone: true, timeserial: "01", serialTimestamp: 1700000050000 } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.data["dead"].tombstonedAt == 1700000050000 +``` + +--- + +## RTLM6d - replaceData with createOp merges initial entries + +**Test ID**: `objects/unit/RTLM6d/replace-data-with-create-op-0` + +**Spec requirement:** If createOp present, merge via RTLM23. + +### Setup +```pseudo +map = LiveMap(objectId: "map:test@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +state_msg = build_object_state("map:test@1000", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "from_sync": { data: { string: "synced" }, timeserial: "01" } + } + }, + createOp: { + mapCreate: { + semantics: "LWW", + entries: { + "from_create": { data: { string: "created" }, timeserial: "00" } + } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.data["from_sync"].data == { string: "synced" } +ASSERT map.data["from_create"].data == { string: "created" } +ASSERT map.createOperationIsMerged == true +``` + +--- + +## RTLM19 - GC removes tombstoned entries past grace period + +**Test ID**: `objects/unit/RTLM19/gc-tombstoned-entries-0` + +**Spec requirement:** Entries where tombstonedAt + gracePeriod <= currentTime are removed. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +grace_period = 86400000 +now = 1700100000000 + +map.data = { + "recent_dead": { data: null, timeserial: "01", tombstone: true, tombstonedAt: now - 1000 }, + "old_dead": { data: null, timeserial: "01", tombstone: true, tombstonedAt: now - grace_period - 1 }, + "alive": { data: { string: "ok" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +map.gcTombstonedEntries(grace_period, now) +``` + +### Assertions +```pseudo +ASSERT "recent_dead" IN map.data +ASSERT "old_dead" NOT IN map.data +ASSERT "alive" IN map.data +``` + +--- + +## RTLM22 - Diff between two data states + +**Test ID**: `objects/unit/RTLM22/diff-calculation-0` + +| Spec | Requirement | +|------|-------------| +| RTLM22b1 | Key in previous but not new -> removed | +| RTLM22b2 | Key in new but not previous -> updated | +| RTLM22b3 | Key in both with different data -> updated | +| RTLM22b | Only non-tombstoned entries are considered | + +### Setup +```pseudo +previousData = { + "removed": { data: { string: "gone" }, timeserial: "01", tombstone: false }, + "changed": { data: { string: "old" }, timeserial: "01", tombstone: false }, + "unchanged": { data: { string: "same" }, timeserial: "01", tombstone: false }, + "was_dead": { data: null, timeserial: "01", tombstone: true } +} + +newData = { + "added": { data: { string: "new" }, timeserial: "02", tombstone: false }, + "changed": { data: { string: "new_val" }, timeserial: "02", tombstone: false }, + "unchanged": { data: { string: "same" }, timeserial: "01", tombstone: false }, + "now_dead": { data: null, timeserial: "02", tombstone: true } +} +``` + +### Test Steps +```pseudo +update = LiveMap.diff(previousData, newData) +``` + +### Assertions +```pseudo +ASSERT update.update["removed"] == "removed" +ASSERT update.update["added"] == "updated" +ASSERT update.update["changed"] == "updated" +ASSERT "unchanged" NOT IN update.update +ASSERT "was_dead" NOT IN update.update +ASSERT "now_dead" NOT IN update.update +``` + +--- + +## RTLM15d4 - Unsupported action is discarded + +**Test ID**: `objects/unit/RTLM15d4/unsupported-action-0` + +**Spec requirement:** Log warning, discard, return false. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +``` + +### Test Steps +```pseudo +msg = ObjectMessage( + serial: "01", siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "root", counterInc: { number: 5 } } +) +result = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT result == false +``` + +--- + +## RTLM6i - replaceData without clearTimeserial resets to null + +**Test ID**: `objects/unit/RTLM6i/replace-data-resets-clear-timeserial-0` + +**Spec requirement:** If ObjectState.map.clearTimeserial is absent, clearTimeserial is reset to null. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.clearTimeserial = "05" +map.data = { + "x": { data: { number: 1 }, timeserial: "03", tombstone: false } +} +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { + semantics: "LWW", + entries: { + "y": { data: { number: 2 }, timeserial: "01" } + } + } +}) +map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.clearTimeserial == null +ASSERT "y" IN map.data +``` + +--- + +## RTLM14c, RTLM5 - MAP_SET referencing tombstoned objectId yields null value + +**Test ID**: `objects/unit/RTLM14c/tombstoned-ref-yields-null-0` + +**Spec requirement:** If entry references an objectId whose LiveObject is tombstoned, the entry is treated as tombstoned (RTLM14c). Value resolution returns null. + +### Setup +```pseudo +pool = ObjectsPool() +tombstoned_counter = LiveCounter(objectId: "counter:dead@1000") +tombstoned_counter.isTombstone = true +pool["counter:dead@1000"] = tombstoned_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:dead@1000" }, timeserial: "01", tombstone: false } +} +``` + +### Assertions +```pseudo +// The entry itself is not tombstoned, but the referenced object is +ASSERT map.data["ref"].tombstone == false +// size() should NOT count this entry because RTLM14c makes it tombstoned +ASSERT map.size() == 0 +// get() should return null for the value +ASSERT map.get("ref") == null +``` + +--- + +## RTLM7 - MAP_SET revives tombstoned entry + +**Test ID**: `objects/unit/RTLM7/map-set-revives-tombstoned-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a2c | Set tombstone to false | +| RTLM7a2d | Set tombstonedAt to null | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: null, timeserial: "01", tombstone: true, tombstonedAt: 1700000000000 } +} +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "name", { string: "Alice" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].data == { string: "Alice" } +ASSERT map.data["name"].tombstone == false +ASSERT map.data["name"].tombstonedAt == null +ASSERT update.update == { "name": "updated" } +``` + +--- + +## RTLM24 - MAP_CLEAR preserves entries with newer serial + +**Test ID**: `objects/unit/RTLM24/map-clear-preserves-newer-0` + +**Spec requirement:** Only entries with timeserial null or <= serial are removed. + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "before": { data: { string: "a" }, timeserial: "03", tombstone: false }, + "after": { data: { string: "b" }, timeserial: "07", tombstone: false }, + "no_ts": { data: { string: "c" }, timeserial: null, tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT "before" NOT IN map.data +ASSERT "no_ts" NOT IN map.data +ASSERT map.data["after"].data == { string: "b" } +ASSERT "before" IN update.update +ASSERT "no_ts" IN update.update +ASSERT "after" NOT IN update.update +``` diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/live_map_api.md new file mode 100644 index 000000000..7a7282246 --- /dev/null +++ b/uts/objects/unit/live_map_api.md @@ -0,0 +1,483 @@ +# LiveMap API Tests + +Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLM5 - get() returns resolved value from LiveMap + +**Test ID**: `objects/unit/RTLM5/get-string-value-0` + +**Spec requirement:** Returns value at key, resolved per RTLM5d2. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Alice" +ASSERT root.get("age").value() == 30 +ASSERT root.get("active").value() == true +``` + +--- + +## RTLM5 - get() returns null for non-existent key + +**Test ID**: `objects/unit/RTLM5/get-nonexistent-key-0` + +**Spec requirement:** If no entry exists at key, return null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").value() == null +``` + +--- + +## RTLM5 - get() resolves objectId to LiveObject + +**Test ID**: `objects/unit/RTLM5/get-objectid-reference-0` + +**Spec requirement:** If data.objectId exists, resolve from pool. Return LiveCounter/LiveMap. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +ASSERT root.get("profile").get("email").value() == "alice@example.com" +``` + +--- + +## RTLM10 - size() returns non-tombstoned entry count + +**Test ID**: `objects/unit/RTLM10/size-non-tombstoned-0` + +**Spec requirement:** Returns number of non-tombstoned entries. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.size() == 7 +``` + +--- + +## RTLM11 - entries() yields key-value pairs + +**Test ID**: `objects/unit/RTLM11/entries-yields-pairs-0` + +**Spec requirement:** Returns non-tombstoned key-value pairs. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = [] +FOR [key, pathObj] IN root.entries(): + entries.append(key) +``` + +### Assertions +```pseudo +ASSERT "name" IN entries +ASSERT "age" IN entries +ASSERT "active" IN entries +ASSERT "score" IN entries +ASSERT "profile" IN entries +ASSERT "data" IN entries +ASSERT "avatar" IN entries +ASSERT entries.length == 7 +``` + +--- + +## RTLM12 - keys() yields only keys + +**Test ID**: `objects/unit/RTLM12/keys-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = list(root.keys()) +``` + +### Assertions +```pseudo +ASSERT keys.length == 7 +ASSERT "name" IN keys +``` + +--- + +## RTLM20 - set() sends MAP_SET message with v6 format + +**Test ID**: `objects/unit/RTLM20/set-sends-map-set-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e2 | action set to MAP_SET | +| RTLM20e3 | objectId set to LiveMap's objectId | +| RTLM20e6 | mapSet.key set | +| RTLM20e7c | mapSet.value.string for string value | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_SET" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapSet.key == "name" +ASSERT obj_msg.operation.mapSet.value.string == "Bob" +``` + +--- + +## RTLM20 - set() with different value types + +**Test ID**: `objects/unit/RTLM20/set-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7b | JsonArray/JsonObject -> mapSet.value.json | +| RTLM20e7d | Number -> mapSet.value.number | +| RTLM20e7e | Boolean -> mapSet.value.boolean | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.set("num_key", 42) +AWAIT root.set("bool_key", false) +AWAIT root.set("json_key", {"nested": true}) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.mapSet.value.number == 42 +ASSERT captured_messages[1].state[0].operation.mapSet.value.boolean == false +ASSERT captured_messages[2].state[0].operation.mapSet.value.json == {"nested": true} +``` + +--- + +## RTLM20e7g - set() with LiveCounterValueType consumes and sends create + set + +**Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7g1 | Consume value type to generate COUNTER_CREATE | +| RTLM20e7g2 | Set mapSet.value.objectId to the created objectId | +| RTLM20h1 | Array: CREATE messages then MAP_SET | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above) +``` + +### Test Steps +```pseudo +AWAIT root.set("new_counter", LiveCounter.create(50)) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +ASSERT state.length == 2 +ASSERT state[0].operation.action == "COUNTER_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "counter:" +ASSERT state[1].operation.action == "MAP_SET" +ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId +``` + +--- + +## RTLM21 - remove() sends MAP_REMOVE message + +**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` + +| Spec | Requirement | +|------|-------------| +| RTLM21e2 | action set to MAP_REMOVE | +| RTLM21e5 | mapRemove.key set | + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as above) +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") +``` + +### Assertions +```pseudo +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" +``` + +--- + +## RTLM20d - set() with echoMessages false throws + +**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` + +**Spec requirement:** If echoMessages is false, throw 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLM21d - remove() with echoMessages false throws + +**Test ID**: `objects/unit/RTLM21d/echo-messages-false-0` + +**Spec requirement:** Same as RTLM20d for remove. + +### Setup +```pseudo +// Same echoMessages: false setup as above +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +``` + +--- + +## RTLM20 - set() applies locally after ACK + +**Test ID**: `objects/unit/RTLM20/set-applies-locally-0` + +**Spec requirement:** Via publishAndApply, local state reflects change after await. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTLM24 - clear() sends MAP_CLEAR message + +**Test ID**: `objects/unit/RTLM24/clear-sends-map-clear-0` + +**Spec requirement:** Constructs MAP_CLEAR ObjectMessage. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup capturing OBJECT messages) +``` + +### Test Steps +```pseudo +instance = root.instance() +AWAIT instance.clear() +``` + +### Assertions +```pseudo +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_CLEAR" +ASSERT obj_msg.operation.objectId == "root" +``` + +--- + +## RTLM20 - Table-driven invalid set value types + +**Test ID**: `objects/unit/RTLM20/set-invalid-values-table-0` + +**Spec requirement:** set() rejects values of unsupported types with error 40013. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +invalid_values = [ + { value: some_function, label: "function" }, + { value: undefined, label: "undefined" }, + { value: some_symbol, label: "symbol" } +] +``` + +### Test Steps +```pseudo +FOR scenario IN invalid_values: + AWAIT root.set("key", scenario.value) FAILS WITH error + ASSERT error.code == 40013 +``` + +--- + +## RTLM20 - set() with bytes value type + +**Test ID**: `objects/unit/RTLM20/set-bytes-value-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20e7f | Binary -> mapSet.value.bytes (base64 encoded) | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("binary_data", bytes([1, 2, 3])) +``` + +### Assertions +```pseudo +ASSERT captured_messages[0].state[0].operation.mapSet.value.bytes == "AQID" +``` diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md new file mode 100644 index 000000000..5f8398e87 --- /dev/null +++ b/uts/objects/unit/live_object_subscribe.md @@ -0,0 +1,244 @@ +# LiveObject Subscribe Tests + +Spec points: `RTLO4b`, `RTLO4c` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTLO4b - subscribe registers listener for data updates + +**Test ID**: `objects/unit/RTLO4b/subscribe-receives-updates-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b3 | User provides listener for data updates | +| RTLO4b4c2 | Listener called with LiveObjectUpdate | +| RTLO4b7 | Returns Subscription object | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +sub = instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT updates.length == 1 +``` + +--- + +## RTLO4b4c1 - noop update does not trigger listener + +**Test ID**: `objects/unit/RTLO4b4c1/noop-no-trigger-0` + +**Spec requirement:** If LiveObjectUpdate is a noop, do nothing. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "remote", + operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } + ) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4c - unsubscribe deregisters listener + +**Test ID**: `objects/unit/RTLO4c/unsubscribe-deregisters-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4c3 | Once deregistered, subsequent updates do not call listener | +| RTLO4c4 | No side effects on channel or RealtimeObject | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +sub = instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +sub.unsubscribe() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "02", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4b1 - subscribe requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTLO4b1/subscribe-requires-mode-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, modes: ["OBJECT_PUBLISH"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +instance.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTLO4b6 - subscribe has no side effects + +**Test ID**: `objects/unit/RTLO4b6/subscribe-no-side-effects-0` + +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +state_before = channel.state +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +instance.subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == state_before +``` + +--- + +## RTLO4b - subscribe on LiveMap receives LiveMapUpdate + +**Test ID**: `objects/unit/RTLO4b/subscribe-map-update-0` + +**Spec requirement:** LiveMapUpdate.update contains key -> "updated"/"removed". + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +``` + +--- + +## RTLO4c1 - unsubscribe requires no channel mode + +**Test ID**: `objects/unit/RTLO4c1/unsubscribe-no-mode-required-0` + +**Spec requirement:** Does not require any specific channel modes. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +instance = root.get("score").instance() +sub = instance.subscribe((event) => {}) +``` + +### Test Steps +```pseudo +sub.unsubscribe() +``` + +### Assertions +```pseudo +// No error thrown +``` diff --git a/uts/objects/unit/object_id.md b/uts/objects/unit/object_id.md new file mode 100644 index 000000000..8f51f7bc9 --- /dev/null +++ b/uts/objects/unit/object_id.md @@ -0,0 +1,159 @@ +# ObjectId Generation Tests + +Spec points: `RTO14` + +## Test Type +Unit test — pure function, no mocks required. + +## Purpose + +Tests the ObjectId generation procedure. ObjectId format is `{type}:{base64url(SHA-256(initialValue:nonce))}@{timestamp}`. This is a deterministic hash-based scheme that ensures uniqueness across clients. + +--- + +## RTO14 - ObjectId format for counter type + +**Test ID**: `objects/unit/RTO14/objectid-format-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTO14a1 | type must be "map" or "counter" | +| RTO14b1 | SHA-256 of UTF-8 encoded "[initialValue]:[nonce]" | +| RTO14b2 | Base64URL encode (RFC 4648 s.5) | +| RTO14c | Format: [type]:[hash]@[timestamp] | + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":42}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT objectId STARTS WITH "counter:" +ASSERT objectId CONTAINS "@1700000000000" +parts = objectId.split(":") +type_part = parts[0] +rest = parts[1] +hash_and_ts = rest.split("@") +hash_part = hash_and_ts[0] +ts_part = hash_and_ts[1] +ASSERT type_part == "counter" +ASSERT ts_part == "1700000000000" +ASSERT hash_part IS valid base64url string +ASSERT hash_part does NOT contain "+" or "/" or "=" +``` + +--- + +## RTO14 - ObjectId format for map type + +**Test ID**: `objects/unit/RTO14/objectid-format-map-0` + +**Spec requirement:** Same format with "map" type prefix. + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "map", + initialValue: '{"map":{"semantics":"LWW","entries":{}}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT objectId STARTS WITH "map:" +ASSERT objectId CONTAINS "@1700000000000" +``` + +--- + +## RTO14 - Deterministic output for same inputs + +**Test ID**: `objects/unit/RTO14/deterministic-0` + +**Spec requirement:** Same type, initialValue, nonce, and timestamp produce the same objectId. + +### Test Steps +```pseudo +id1 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "same-nonce-1234567", + timestamp: 1700000000000 +) +id2 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "same-nonce-1234567", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT id1 == id2 +``` + +--- + +## RTO14 - Different nonce produces different objectId + +**Test ID**: `objects/unit/RTO14/different-nonce-0` + +**Spec requirement:** Nonce ensures uniqueness across clients. + +### Test Steps +```pseudo +id1 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "nonce-aaaaaaaaaaaaa", + timestamp: 1700000000000 +) +id2 = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "nonce-bbbbbbbbbbbbb", + timestamp: 1700000000000 +) +``` + +### Assertions +```pseudo +ASSERT id1 != id2 +``` + +--- + +## RTO14b - SHA-256 hash is base64url encoded (not standard base64) + +**Test ID**: `objects/unit/RTO14b/base64url-encoding-0` + +| Spec | Requirement | +|------|-------------| +| RTO14b2 | Must use URL-safe Base64 per RFC 4648 s.5, not standard Base64 | + +### Test Steps +```pseudo +objectId = generateObjectId( + type: "counter", + initialValue: '{"counter":{"count":0}}', + nonce: "test-nonce-12345678", + timestamp: 1700000000000 +) +hash_part = objectId.split(":")[1].split("@")[0] +``` + +### Assertions +```pseudo +ASSERT hash_part does NOT contain "+" +ASSERT hash_part does NOT contain "/" +ASSERT hash_part does NOT end with "=" +``` diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md new file mode 100644 index 000000000..214fe7db0 --- /dev/null +++ b/uts/objects/unit/objects_pool.md @@ -0,0 +1,910 @@ +# ObjectsPool Tests + +Spec points: `RTO3`–`RTO9` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `ObjectsPool` internal data structure and sync state machine. ObjectsPool is a `Dict` that manages all objects on a channel. It processes ATTACHED messages (to determine sync mode), OBJECT_SYNC messages (to build state from server), and OBJECT messages (to apply operations). It maintains a SyncObjectsPool for accumulating sync data, buffers operations during SYNCING, and manages the INITIALIZED -> SYNCING -> SYNCED state transitions. + +Tests operate directly on ObjectsPool by calling `processAttached()`, `processObjectSync()`, and `processObjectMessage()`. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJECTS. + +--- + +## RTO3 - ObjectsPool initialization with root LiveMap + +**Test ID**: `objects/unit/RTO3/pool-init-root-0` + +| Spec | Requirement | +|------|-------------| +| RTO3a | ObjectsPool is Dict | +| RTO3b | Must always contain a LiveMap with id "root" | +| RTO3b1 | On initialization, create zero-value LiveMap with objectId "root" | + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Assertions +```pseudo +ASSERT "root" IN pool +ASSERT pool["root"] IS LiveMap +ASSERT pool["root"].data == {} +ASSERT pool["root"].objectId == "root" +``` + +--- + +## RTO4a - ATTACHED with HAS_OBJECTS flag starts SYNCING + +**Test ID**: `objects/unit/RTO4/attached-has-objects-syncing-0` + +| Spec | Requirement | +|------|-------------| +| RTO4c | Sync state transitions to SYNCING | +| RTO4d | bufferedObjectOperations cleared | +| RTO4a | HAS_OBJECTS=1 means server will send OBJECT_SYNC | + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + channelSerial: "sync1:cursor", + flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +``` + +--- + +## RTO4b - ATTACHED without HAS_OBJECTS clears pool and goes to SYNCED + +**Test ID**: `objects/unit/RTO4b/attached-no-objects-synced-0` + +| Spec | Requirement | +|------|-------------| +| RTO4b1 | Remove all objects except root | +| RTO4b2 | Clear root LiveMap data to zero-value | +| RTO4b2a | Emit LiveMapUpdate for root with removed entries | +| RTO4b4 | Perform sync completion actions | + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["root"].data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, + channel: "test", + flags: 0 +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:abc@1000" NOT IN pool +ASSERT "root" IN pool +ASSERT pool["root"].data == {} +ASSERT updates.length >= 1 +ASSERT updates[0].update == { "name": "removed" } +``` + +--- + +## RTO5 - OBJECT_SYNC complete sequence + +**Test ID**: `objects/unit/RTO5/sync-complete-sequence-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a1 | channelSerial is "sequenceId:cursor" | +| RTO5a4 | Sync complete when cursor is empty | +| RTO5f1 | Store new entries in SyncObjectsPool | +| RTO5c8 | Transition to SYNCED | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "Alice" }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 42 }, + createOp: { counterCreate: { count: 42 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "root" IN pool +ASSERT "counter:abc@1000" IN pool +ASSERT pool["root"].data["name"].data == { string: "Alice" } +ASSERT pool["counter:abc@1000"].data == 42 +``` + +--- + +## RTO5a2 - New sync sequence discards previous + +**Test ID**: `objects/unit/RTO5a2/new-sequence-discards-old-0` + +| Spec | Requirement | +|------|-------------| +| RTO5a2a | SyncObjectsPool must be cleared | +| RTO5a2 | New sequence id starts fresh sync | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "seq1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "seq1:more", [ + build_object_state("counter:old@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "seq2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:old@1000" NOT IN pool +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5f2a - Partial object state merge for maps + +**Test ID**: `objects/unit/RTO5f2a/partial-map-merge-0` + +| Spec | Requirement | +|------|-------------| +| RTO5f2 | Existing entry: partial state, merge into existing | +| RTO5f2a2 | Merge map entries from incoming into existing | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "Alice" }, timeserial: "t:0" } } + } + }) +])) + +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "age": { data: { number: 30 }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["root"].data["name"].data == { string: "Alice" } +ASSERT pool["root"].data["age"].data == { number: 30 } +``` + +--- + +## RTO5c2 - Sync completion removes objects not in sync + +**Test ID**: `objects/unit/RTO5c2/remove-absent-objects-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c2 | Remove objects not received during sync | +| RTO5c2a | root must not be removed | + +### Setup +```pseudo +pool = ObjectsPool() +pool["counter:old@1000"] = LiveCounter(objectId: "counter:old@1000") +pool["counter:old@1000"].data = 99 +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT "counter:old@1000" NOT IN pool +ASSERT "root" IN pool +``` + +--- + +## RTO5c9 - Sync completion clears appliedOnAckSerials + +**Test ID**: `objects/unit/RTO5c9/clear-applied-on-ack-serials-0` + +**Spec requirement:** appliedOnAckSerials set must be cleared after sync. + +### Setup +```pseudo +pool = ObjectsPool() +pool.appliedOnAckSerials = {"serial-1", "serial-2"} +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.appliedOnAckSerials == {} +``` + +--- + +## RTO7, RTO8a - OBJECT messages buffered during SYNCING + +**Test ID**: `objects/unit/RTO8a/buffer-during-syncing-0` + +| Spec | Requirement | +|------|-------------| +| RTO8a | If sync state is not SYNCED, buffer ObjectMessages | +| RTO7a | bufferedObjectOperations is an array | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +ASSERT pool.bufferedObjectOperations.length == 1 +ASSERT "counter:abc@1000" NOT IN pool +``` + +--- + +## RTO5c6, RTO8b - Buffered operations applied on sync completion + +**Test ID**: `objects/unit/RTO5c6/apply-buffered-on-sync-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c6 | Apply buffered operations with source CHANNEL | +| RTO8b | When SYNCED, apply directly | + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 10, "02", "site1") +])) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 110 +ASSERT pool.bufferedObjectOperations.length == 0 +``` + +--- + +## RTO9a1 - Null operation is discarded with warning + +**Test ID**: `objects/unit/RTO9a1/null-operation-warning-0` + +**Spec requirement:** If ObjectMessage.operation is null or omitted, log warning and discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage(serial: "01", siteCode: "site1", operation: null) +])) +``` + +### Assertions +```pseudo +ASSERT pool.keys().length == 1 +``` + +--- + +## RTO9a3 - appliedOnAckSerials deduplication + +**Test ID**: `objects/unit/RTO9a3/dedup-applied-on-ack-0` + +**Spec requirement:** If appliedOnAckSerials contains the serial, log debug, remove from set, and discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +pool["counter:abc@1000"].data = 10 +pool.appliedOnAckSerials = {"echo-serial-1"} +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage( + serial: "echo-serial-1", + siteCode: "site1", + operation: { action: "COUNTER_INC", objectId: "counter:abc@1000", counterInc: { number: 5 } } + ) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 10 +ASSERT "echo-serial-1" NOT IN pool.appliedOnAckSerials +``` + +--- + +## RTO9a2a4 - LOCAL source adds serial to appliedOnAckSerials + +**Test ID**: `objects/unit/RTO9a2a4/local-source-adds-serial-0` + +**Spec requirement:** If source is LOCAL and operation was applied successfully, add serial to appliedOnAckSerials. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +pool["counter:abc@1000"] = LiveCounter(objectId: "counter:abc@1000") +``` + +### Test Steps +```pseudo +pool.applyObjectMessages([ + build_counter_inc("counter:abc@1000", 5, "local-serial-1", "test-site") +], source: LOCAL) +``` + +### Assertions +```pseudo +ASSERT "local-serial-1" IN pool.appliedOnAckSerials +ASSERT pool["counter:abc@1000"].data == 5 +``` + +--- + +## RTO9a2b - Unsupported action is discarded with warning + +**Test ID**: `objects/unit/RTO9a2b/unsupported-action-warning-0` + +**Spec requirement:** Log warning, discard. + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "site1", + operation: { action: "UNKNOWN_ACTION", objectId: "counter:abc@1000" } + ) +])) +``` + +### Assertions +```pseudo +ASSERT pool.keys().length == 1 +``` + +--- + +## RTO6 - Zero-value object creation from objectId prefix + +**Test ID**: `objects/unit/RTO6/zero-value-from-prefix-0` + +| Spec | Requirement | +|------|-------------| +| RTO6b1 | Parse type from objectId prefix before ":" | +| RTO6b2 | "map" prefix creates zero-value LiveMap | +| RTO6b3 | "counter" prefix creates zero-value LiveCounter | +| RTO6a | Skip if object already exists | + +### Setup +```pseudo +pool = ObjectsPool() +pool.syncState = SYNCED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:new@2000", 5, "01", "site1") +])) +pool.processObjectMessage(build_object_message("test", [ + build_map_set("map:new@2000", "key", { string: "val" }, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT "counter:new@2000" IN pool +ASSERT pool["counter:new@2000"] IS LiveCounter +ASSERT pool["counter:new@2000"].data == 5 + +ASSERT "map:new@2000" IN pool +ASSERT pool["map:new@2000"] IS LiveMap +ASSERT pool["map:new@2000"].data["key"].data == { string: "val" } +``` + +--- + +## RTO5d - OBJECT_SYNC with null object field is skipped + +**Test ID**: `objects/unit/RTO5d/null-object-skipped-0` + +**Spec requirement:** If ObjectMessage.object is null or omitted, skip processing. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + ObjectMessage(object: null), + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +``` + +--- + +## RTO5f3 - OBJECT_SYNC with unsupported object type is skipped + +**Test ID**: `objects/unit/RTO5f3/unsupported-type-skipped-0` + +**Spec requirement:** If neither map nor counter is present, log warning and skip. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + ObjectMessage(object: { objectId: "unknown:xyz@1000", siteTimeserials: {} }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "unknown:xyz@1000" NOT IN pool +``` + +--- + +## RTO5e - OBJECT_SYNC transitions to SYNCING + +**Test ID**: `objects/unit/RTO5e/object-sync-transitions-syncing-0` + +**Spec requirement:** When OBJECT_SYNC received, sync state must transition to SYNCING if not already. + +### Setup +```pseudo +pool = ObjectsPool() +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCING +``` + +--- + +## RTO5c7 - Sync completion emits updates for existing objects + +**Test ID**: `objects/unit/RTO5c7/sync-emits-updates-0` + +**Spec requirement:** For each previously existing object updated by sync, emit the stored LiveObjectUpdate. + +### Setup +```pseudo +pool = ObjectsPool() +pool["root"].data = { + "name": { data: { string: "Old" }, timeserial: "01", tombstone: false } +} + +updates = [] +pool["root"].subscribe((update) => updates.append(update)) + +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { "name": { data: { string: "New" }, timeserial: "t:0" } } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length >= 1 +ASSERT "name" IN updates[0].update +ASSERT updates[0].update["name"] == "updated" +``` + +--- + +## RTO5f2b - Partial counter state logs error + +**Test ID**: `objects/unit/RTO5f2b/partial-counter-error-0` + +**Spec requirement:** If counter is present on partial merge, log error and skip. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { counter: { count: 5 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool["counter:abc@1000"].data == 10 +``` + +--- + +## RTO4d - ATTACHED clears buffered operations + +**Test ID**: `objects/unit/RTO4d/attached-clears-buffer-0` + +**Spec requirement:** On ATTACHED, bufferedObjectOperations is cleared. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +``` + +### Assertions +```pseudo +ASSERT pool.bufferedObjectOperations.length == 0 +``` + +--- + +## RTO4, RTO5 - ATTACHED during SYNCING resets sync + +**Test ID**: `objects/unit/RTO4-RTO5/attached-during-syncing-resets-0` + +**Spec requirement:** A new ATTACHED message during SYNCING resets the sync state machine. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:more", [ + build_object_state("counter:old@1000", {"aaa": "t:0"}, { counter: { count: 10 } }) +])) +ASSERT pool.syncState == SYNCING +``` + +### Test Steps +```pseudo +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectSync(build_object_sync_message("test", "sync2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:new@1000", {"aaa": "t:0"}, { counter: { count: 99 } }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "counter:old@1000" NOT IN pool +ASSERT "counter:new@1000" IN pool +``` + +--- + +## RTO5, RTO7 - New OBJECT_SYNC sequence does NOT clear buffer + +**Test ID**: `objects/unit/RTO5-RTO7/new-sync-keeps-buffer-0` + +**Spec requirement:** When a new OBJECT_SYNC sequence starts (RTO5a2), only the SyncObjectsPool is discarded. Buffered OBJECT messages are retained for application after sync completion. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "seq2:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { semantics: "LWW", entries: {} }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT pool["counter:abc@1000"].data == 105 +``` + +--- + +## RTO7, RTO8 - OBJECT messages buffered even without preceding ATTACHED + +**Test ID**: `objects/unit/RTO7-RTO8/buffer-without-attached-0` + +**Spec requirement:** RTO8a: if sync state is not SYNCED, buffer ObjectMessages. This applies regardless of whether ATTACHED was received — INITIALIZED state also buffers. + +### Setup +```pseudo +pool = ObjectsPool() +ASSERT pool.syncState == INITIALIZED +``` + +### Test Steps +```pseudo +pool.processObjectMessage(build_object_message("test", [ + build_counter_inc("counter:abc@1000", 5, "01", "site1") +])) +``` + +### Assertions +```pseudo +ASSERT pool.bufferedObjectOperations.length == 1 +``` + +--- + +## RTO5c, RTLM23 - Sync with clearTimeserial hides initial createOp entries + +**Test ID**: `objects/unit/RTO5c-RTLM23/sync-clear-timeserial-hides-create-entries-0` + +**Spec requirement:** When a map's ObjectState includes a clearTimeserial, createOp entries with serials <= clearTimeserial are rejected during merge. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: {}, + clearTimeserial: "05" + }, + createOp: { + mapCreate: { + semantics: "LWW", + entries: { + "old_key": { data: { string: "old" }, timeserial: "03" }, + "new_key": { data: { string: "new" }, timeserial: "07" } + } + } + } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED +ASSERT "old_key" NOT IN pool["root"].data +ASSERT pool["root"].data["new_key"].data == { string: "new" } +``` diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md new file mode 100644 index 000000000..5a83c8e9c --- /dev/null +++ b/uts/objects/unit/path_object.md @@ -0,0 +1,603 @@ +# PathObject Read Operations Tests + +Spec points: `RTPO1`–`RTPO14` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO4 - path() returns dot-delimited string + +**Test ID**: `objects/unit/RTPO4/path-string-representation-0` + +| Spec | Requirement | +|------|-------------| +| RTPO4a | Dot-delimited string of path segments | +| RTPO4c | Empty path returns empty string | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.path() == "" +ASSERT root.get("profile").path() == "profile" +ASSERT root.get("profile").get("email").path() == "profile.email" +``` + +--- + +## RTPO4b - path() escapes dots in segments + +**Test ID**: `objects/unit/RTPO4b/path-escapes-dots-0` + +**Spec requirement:** Dot characters within segments are escaped with backslash. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.get("a.b").get("c") +``` + +### Assertions +```pseudo +ASSERT po.path() == "a\\.b.c" +``` + +--- + +## RTPO5 - get() returns new PathObject with appended key + +**Test ID**: `objects/unit/RTPO5/get-appends-key-0` + +| Spec | Requirement | +|------|-------------| +| RTPO5c | New PathObject with key appended | +| RTPO5d | Purely navigational, no resolution | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +child = root.get("profile") +grandchild = child.get("email") +``` + +### Assertions +```pseudo +ASSERT child.path() == "profile" +ASSERT grandchild.path() == "profile.email" +ASSERT child IS NOT root +``` + +--- + +## RTPO5b - get() throws on non-string key + +**Test ID**: `objects/unit/RTPO5b/get-non-string-throws-0` + +**Spec requirement:** If key is not String, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.get(123) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO6 - at() parses dot-delimited path + +**Test ID**: `objects/unit/RTPO6/at-parses-path-0` + +| Spec | Requirement | +|------|-------------| +| RTPO6b | Parses dots as separators, backslash-escaped dots as literal | +| RTPO6d | Equivalent to chained get() calls | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.at("profile.email") +``` + +### Assertions +```pseudo +ASSERT po.path() == "profile.email" +ASSERT po.value() == "alice@example.com" +``` + +--- + +## RTPO6 - at() respects escaped dots + +**Test ID**: `objects/unit/RTPO6/at-escaped-dots-0` + +**Spec requirement:** `\.` is a literal dot within a segment. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +po = root.at("a\\.b.c") +``` + +### Assertions +```pseudo +ASSERT po.path() == "a\\.b.c" +``` + +--- + +## RTPO7 - value() returns counter numeric value + +**Test ID**: `objects/unit/RTPO7/value-counter-0` + +**Spec requirement:** If resolved value is LiveCounter, returns numeric value. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTPO7 - value() returns primitive value + +**Test ID**: `objects/unit/RTPO7/value-primitive-0` + +**Spec requirement:** If resolved value is a primitive, returns the value directly. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Alice" +ASSERT root.get("age").value() == 30 +ASSERT root.get("active").value() == true +``` + +--- + +## RTPO7d - value() returns null for LiveMap + +**Test ID**: `objects/unit/RTPO7d/value-livemap-null-0` + +**Spec requirement:** If resolved value is a LiveMap, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("profile").value() == null +``` + +--- + +## RTPO7e - value() returns null on resolution failure + +**Test ID**: `objects/unit/RTPO7e/value-unresolvable-null-0` + +**Spec requirement:** If path resolution fails, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").get("deep").value() == null +``` + +--- + +## RTPO8 - instance() returns Instance for LiveObject + +**Test ID**: `objects/unit/RTPO8/instance-live-object-0` + +| Spec | Requirement | +|------|-------------| +| RTPO8b | LiveMap or LiveCounter -> Instance wrapping that object | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +counter_inst = root.get("score").instance() +ASSERT counter_inst IS Instance +ASSERT counter_inst.id() == "counter:score@1000" + +map_inst = root.get("profile").instance() +ASSERT map_inst IS Instance +ASSERT map_inst.id() == "map:profile@1000" +``` + +--- + +## RTPO8c - instance() returns null for primitive + +**Test ID**: `objects/unit/RTPO8c/instance-primitive-null-0` + +**Spec requirement:** If resolved value is a primitive, returns null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("name").instance() == null +``` + +--- + +## RTPO9 - entries() yields [key, PathObject] pairs + +**Test ID**: `objects/unit/RTPO9/entries-yields-pairs-0` + +| Spec | Requirement | +|------|-------------| +| RTPO9b | Iterator of [key, PathObject] for LiveMap entries | +| RTPO9c | Only non-tombstoned entries | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = {} +FOR [key, pathObj] IN root.entries(): + entries[key] = pathObj.path() +``` + +### Assertions +```pseudo +ASSERT entries["name"] == "name" +ASSERT entries["profile"] == "profile" +ASSERT entries.length == 7 +``` + +--- + +## RTPO9d - entries() returns empty iterator for non-LiveMap + +**Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` + +**Spec requirement:** If resolved value is not LiveMap or resolution fails, return empty iterator. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = list(root.get("score").entries()) +``` + +### Assertions +```pseudo +ASSERT entries.length == 0 +``` + +--- + +## RTPO12 - size() returns non-tombstoned count + +**Test ID**: `objects/unit/RTPO12/size-count-0` + +**Spec requirement:** For LiveMap, returns non-tombstoned entry count. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.size() == 7 +ASSERT root.get("profile").size() == 3 +``` + +--- + +## RTPO12c - size() returns null for non-LiveMap + +**Test ID**: `objects/unit/RTPO12c/size-non-map-null-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").size() == null +ASSERT root.get("name").size() == null +``` + +--- + +## RTPO13 - compact() recursively compacts LiveMap tree + +**Test ID**: `objects/unit/RTPO13/compact-recursive-0` + +| Spec | Requirement | +|------|-------------| +| RTPO13b1 | Each entry included, tombstoned excluded | +| RTPO13b2 | Nested LiveMap recursively compacted | +| RTPO13b3 | Nested LiveCounter resolved to number | +| RTPO13b4 | Primitives as-is | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = root.compact() +``` + +### Assertions +```pseudo +ASSERT result["name"] == "Alice" +ASSERT result["age"] == 30 +ASSERT result["active"] == true +ASSERT result["score"] == 100 +ASSERT result["data"] == {"tags": ["a", "b"]} +ASSERT result["avatar"] IS bytes [1, 2, 3] +ASSERT result["profile"]["email"] == "alice@example.com" +ASSERT result["profile"]["nested_counter"] == 5 +ASSERT result["profile"]["prefs"]["theme"] == "dark" +``` + +--- + +## RTPO13b5 - compact() handles cycles via shared reference + +**Test ID**: `objects/unit/RTPO13b5/compact-cycle-detection-0` + +**Spec requirement:** Cyclic references reuse the already-compacted in-memory object. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") +])) +``` + +### Test Steps +```pseudo +result = root.get("profile").compact() +``` + +### Assertions +```pseudo +ASSERT result["prefs"]["back_ref"] IS result +``` + +--- + +## RTPO13c - compact() returns number for LiveCounter + +**Test ID**: `objects/unit/RTPO13c/compact-counter-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").compact() == 100 +``` + +--- + +## RTPO14 - compactJson() encodes binary as base64 and cycles as objectId + +**Test ID**: `objects/unit/RTPO14/compact-json-0` + +| Spec | Requirement | +|------|-------------| +| RTPO14a1 | Binary as base64 strings | +| RTPO14a2 | Cycles as {objectId: ...} | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "back_ref", { objectId: "map:profile@1000" }, "99", "remote") +])) +``` + +### Test Steps +```pseudo +result = root.get("profile").compactJson() +``` + +### Assertions +```pseudo +ASSERT result["prefs"]["back_ref"] == { "objectId": "map:profile@1000" } +``` + +--- + +## RTPO3 - Path resolution walks through LiveMaps + +**Test ID**: `objects/unit/RTPO3/path-resolution-walk-0` + +| Spec | Requirement | +|------|-------------| +| RTPO3a | Walk segments through LiveMaps | +| RTPO3b | Empty path resolves to root | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.value() == null +ASSERT root.get("profile").get("prefs").get("theme").value() == "dark" +``` + +--- + +## RTPO3a1 - Resolution fails if intermediate is not LiveMap + +**Test ID**: `objects/unit/RTPO3a1/intermediate-not-map-0` + +**Spec requirement:** Current object must be a LiveMap. If not, resolution fails. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("score").get("something").value() == null +``` + +--- + +## RTPO3c1 - Read operation returns null on resolution failure + +**Test ID**: `objects/unit/RTPO3c1/read-null-on-failure-0` + +**Spec requirement:** For read operations, return null. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("nonexistent").value() == null +ASSERT root.get("nonexistent").instance() == null +ASSERT root.get("nonexistent").size() == null +ASSERT root.get("nonexistent").compact() == null +``` + +--- + +## RTPO6b - at() throws for non-string input + +**Test ID**: `objects/unit/RTPO6b/at-non-string-throws-0` + +**Spec requirement:** If path is not String, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.at(123) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO7 - value() returns bytes for binary entry + +**Test ID**: `objects/unit/RTPO7/value-bytes-0` + +**Spec requirement:** If resolved value is bytes, returns the raw binary data. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root.get("avatar").value() IS bytes [1, 2, 3] +``` + +--- + +## RTPO14 - compactJson() encodes bytes as base64 string + +**Test ID**: `objects/unit/RTPO14/compact-json-bytes-0` + +**Spec requirement:** Binary values encoded as base64 strings in JSON representation. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +result = root.compactJson() +``` + +### Assertions +```pseudo +ASSERT result["avatar"] == "AQID" +``` diff --git a/uts/objects/unit/path_object_mutations.md b/uts/objects/unit/path_object_mutations.md new file mode 100644 index 000000000..ef33a1a15 --- /dev/null +++ b/uts/objects/unit/path_object_mutations.md @@ -0,0 +1,321 @@ +# PathObject Write Operations Tests + +Spec points: `RTPO15`–`RTPO18`, `RTPO3c2` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO15 - set() delegates to LiveMap#set + +**Test ID**: `objects/unit/RTPO15/set-delegates-to-map-0` + +| Spec | Requirement | +|------|-------------| +| RTPO15b | Resolves path, on failure throws RTPO3c2 | +| RTPO15c | LiveMap -> delegates to LiveMap#set | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == "Bob" +``` + +--- + +## RTPO15 - set() on nested path + +**Test ID**: `objects/unit/RTPO15/set-nested-path-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("profile").set("email", "bob@example.com") +``` + +### Assertions +```pseudo +ASSERT root.get("profile").get("email").value() == "bob@example.com" +``` + +--- + +## RTPO15d - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTPO15d/set-non-map-throws-0` + +**Spec requirement:** If resolved value is not a LiveMap, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO16 - remove() delegates to LiveMap#remove + +**Test ID**: `objects/unit/RTPO16/remove-delegates-to-map-0` + +| Spec | Requirement | +|------|-------------| +| RTPO16b | Resolves path, on failure throws RTPO3c2 | +| RTPO16c | LiveMap -> delegates to LiveMap#remove | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.remove("name") +``` + +### Assertions +```pseudo +ASSERT root.get("name").value() == null +``` + +--- + +## RTPO16d - remove() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTPO16d/remove-non-map-throws-0` + +**Spec requirement:** If resolved value is not a LiveMap, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").remove("key") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO17 - increment() delegates to LiveCounter#increment + +**Test ID**: `objects/unit/RTPO17/increment-delegates-to-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTPO17b | Resolves path, on failure throws RTPO3c2 | +| RTPO17c | LiveCounter -> delegates to LiveCounter#increment | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment(25) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 125 +``` + +--- + +## RTPO17 - increment() defaults to 1 + +**Test ID**: `objects/unit/RTPO17/increment-default-amount-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTPO17d - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTPO17d/increment-non-counter-throws-0` + +**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO18 - decrement() delegates to LiveCounter#decrement + +**Test ID**: `objects/unit/RTPO18/decrement-delegates-to-counter-0` + +| Spec | Requirement | +|------|-------------| +| RTPO18b | Resolves path, on failure throws RTPO3c2 | +| RTPO18c | LiveCounter -> delegates to LiveCounter#decrement | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").decrement(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 90 +``` + +--- + +## RTPO18 - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTPO18/decrement-default-amount-0` + +**Spec requirement:** amount defaults to 1. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("score").decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + +## RTPO18d - decrement() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTPO18d/decrement-non-counter-throws-0` + +**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.decrement(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92007 +``` + +--- + +## RTPO3c2 - set() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/set-unresolvable-throws-0` + +**Spec requirement:** For write operations, if path resolution fails, throw 92005. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +``` + +--- + +## RTPO3c2 - increment() on unresolvable path throws 92005 + +**Test ID**: `objects/unit/RTPO3c2/increment-unresolvable-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").increment(5) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +``` diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md new file mode 100644 index 000000000..503ac43f2 --- /dev/null +++ b/uts/objects/unit/path_object_subscribe.md @@ -0,0 +1,618 @@ +# PathObject Subscribe Tests + +Spec points: `RTPO19`–`RTPO21`, `RTO24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. + +--- + +## RTPO19 - subscribe() returns Subscription and receives events + +**Test ID**: `objects/unit/RTPO19/subscribe-receives-events-0` + +| Spec | Requirement | +|------|-------------| +| RTPO19c | Returns Subscription object | +| RTPO19d1 | Event.object is a PathObject pointing to change path | +| RTPO19d2 | Event.message is the ObjectMessage | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT events.length == 1 +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].message IS NOT null +``` + +--- + +## RTPO19b1b - subscribe() with depth 1 only receives self events + +**Test ID**: `objects/unit/RTPO19b1b/subscribe-depth-1-self-only-0` + +**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event), { depth: 1 }) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +``` + +--- + +## RTPO19b1c - subscribe() with depth 2 receives self and children + +**Test ID**: `objects/unit/RTPO19b1c/subscribe-depth-2-children-0` + +**Spec requirement:** depth=n means changes up to n-1 levels of children trigger the listener. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event), { depth: 2 }) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "101", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 2 +``` + +--- + +## RTPO19b1a - subscribe() with no depth receives all descendants + +**Test ID**: `objects/unit/RTPO19b1a/subscribe-unlimited-depth-0` + +**Spec requirement:** If depth is undefined, subscription receives events at any depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) +poll_until(events.length >= 3, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 3 +``` + +--- + +## RTPO19b1d - subscribe() with non-positive depth throws 40003 + +**Test ID**: `objects/unit/RTPO19b1d/subscribe-non-positive-depth-throws-0` + +**Spec requirement:** If depth is provided and is not a positive integer, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: 0 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19b1d - subscribe() with negative depth throws 40003 + +**Test ID**: `objects/unit/RTPO19b1d/subscribe-negative-depth-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: -1 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19e - subscribe() follows path not identity + +**Test ID**: `objects/unit/RTPO19e/subscribe-follows-path-0` + +**Spec requirement:** If the object at the path changes identity, the subscription continues to deliver events for the new object. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +// Replace the counter at "score" with a new counter +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) + +// Increment the NEW counter at "score" +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:new@2000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Should receive event for the new counter, since subscription follows path +found_new = false +FOR event IN events: + IF event.object.path() == "score": + found_new = true +ASSERT found_new == true +``` + +--- + +## RTPO19f - child events bubble up to parent subscription + +**Test ID**: `objects/unit/RTPO19f/child-events-bubble-0` + +**Spec requirement:** Events at child paths bubble up subject to depth filtering. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("profile").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 2 +``` + +--- + +## RTO24b3 - depth filtering formula + +**Test ID**: `objects/unit/RTO24b3/depth-filtering-formula-0` + +**Spec requirement:** Event dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +// Subscribe at "profile" with depth 2: +// self (profile) → segmentDiff=0, 0+1=1 ≤ 2 ✓ +// child (profile.email) → segmentDiff=1, 1+1=2 ≤ 2 ✓ +// grandchild (profile.prefs.theme) → segmentDiff=2, 2+1=3 > 2 ✗ +root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) +``` + +### Test Steps +```pseudo +// Self event (profile map update) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) + +// Child event (nested counter) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +// Grandchild event (prefs.theme) — should NOT be received +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 2 +``` + +--- + +## RTO24b5 - listener exception does not affect other listeners + +**Test ID**: `objects/unit/RTO24b5/listener-exception-caught-0` + +**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => { THROW Error("boom") }) +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +``` + +--- + +## RTPO20 - unsubscribe() deregisters listener + +**Test ID**: `objects/unit/RTPO20/unsubscribe-deregisters-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) +sub.unsubscribe() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT events.length == 0 +``` + +--- + +## RTPO19g - subscribe() has no side effects + +**Test ID**: `objects/unit/RTPO19g/subscribe-no-side-effects-0` + +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +state_before = channel.state +``` + +### Test Steps +```pseudo +root.get("score").subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == state_before +``` + +--- + +## RTPO19 - MAP_CLEAR triggers subscription events on child paths + +**Test ID**: `objects/unit/RTPO19/map-clear-triggers-child-events-0` + +**Spec requirement:** When MAP_CLEAR is applied, subscriptions on affected child paths receive events. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_clear("root", "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +``` + +--- + +## RTPO19 - subscribe() on primitive path receives change events + +**Test ID**: `objects/unit/RTPO19/subscribe-primitive-path-0` + +**Spec requirement:** A subscription on a path pointing to a primitive (e.g., root.get("name")) fires when the map entry at that key changes. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("name").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events.length == 1 +ASSERT events[0].object.path() == "name" +``` + +--- + +## RTPO19d - subscribe() event provides correct PathObject + +**Test ID**: `objects/unit/RTPO19d/event-path-object-correct-0` + +**Spec requirement:** RTPO19d1: event.object is a PathObject pointing to the change location. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].object.value() == 107 +``` + +--- + +## RTPO21 - subscribeIterator() yields events + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-yields-0` + +| Spec | Requirement | +|------|-------------| +| RTPO21b | Returns async iterable of PathObjectSubscriptionEvent | +| RTPO21d | Each iteration yields next event | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter = root.get("score").subscribeIterator() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) + +event = AWAIT iter.next() +``` + +### Assertions +```pseudo +ASSERT event.object IS PathObject +ASSERT event.object.path() == "score" +``` + +--- + +## RTPO21 - subscribeIterator() with depth option + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-depth-0` + +**Spec requirement:** subscribeIterator accepts same options as subscribe, including depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter = root.subscribeIterator({ depth: 1 }) +``` + +### Test Steps +```pseudo +// Self event (depth 1 allows) +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "99", "remote") +])) +event = AWAIT iter.next() + +// Child event (depth 1 rejects — counter at depth 2) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT event.object.path() == "" +``` + +--- + +## RTPO21 - subscribeIterator() break cleanup + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-break-cleanup-0` + +**Spec requirement:** Breaking out of the iterator loop cleans up the underlying subscription. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +received = [] +``` + +### Test Steps +```pseudo +iter = root.get("score").subscribeIterator() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 1, "99", "remote") +])) + +event = AWAIT iter.next() +received.append(event) + +// Break the iterator (cleanup) +iter.return() + +// Further events should not be received +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 1, "100", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT received.length == 1 +``` + +--- + +## RTPO21 - subscribeIterator() multiple concurrent iterators + +**Test ID**: `objects/unit/RTPO21/subscribe-iterator-concurrent-0` + +**Spec requirement:** Multiple iterators can coexist independently. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +iter1 = root.get("score").subscribeIterator() +iter2 = root.get("score").subscribeIterator() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "99", "remote") +])) + +event1 = AWAIT iter1.next() +event2 = AWAIT iter2.next() +``` + +### Assertions +```pseudo +ASSERT event1.object.path() == "score" +ASSERT event2.object.path() == "score" +``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md new file mode 100644 index 000000000..fd833be65 --- /dev/null +++ b/uts/objects/unit/realtime_object.md @@ -0,0 +1,927 @@ +# RealtimeObject Tests + +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_channel_no_ack`, and builder functions. + +--- + +## RTO23 - get() returns PathObject wrapping root + +**Test ID**: `objects/unit/RTO23/get-returns-path-object-0` + +| Spec | Requirement | +|------|-------------| +| RTO23d | Returns PathObject wrapping root LiveMap with empty path | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT root.path() == "" +``` + +--- + +## RTO23a - get() requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTO23a/get-requires-subscribe-mode-0` + +**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTO23b - get() throws on DETACHED or FAILED channel + +**Test ID**: `objects/unit/RTO23b/get-throws-detached-0` + +**Spec requirement:** If channel is DETACHED or FAILED, throw 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ) +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +``` + +--- + +## RTO23c - get() waits for SYNCED state + +**Test ID**: `objects/unit/RTO23c/get-waits-for-synced-0` + +**Spec requirement:** If sync state is not SYNCED, waits for SYNCED transition. + +### Setup +```pseudo +attach_sent = false +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_sent = true + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:cursor", + flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +get_future = channel.object.get() + +poll_until(attach_sent, timeout: 5s) + +mock_ws.send_to_client(build_object_sync_message( + "test", "sync1:", STANDARD_POOL_OBJECTS +)) + +root = AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +``` + +--- + +## RTO15 - publish sends OBJECT ProtocolMessage + +**Test ID**: `objects/unit/RTO15/publish-sends-object-pm-0` + +| Spec | Requirement | +|------|-------------| +| RTO15e1 | action set to OBJECT | +| RTO15e2 | channel set to channel name | +| RTO15e3 | state set to encoded ObjectMessages | +| RTO15h | Returns PublishResult from ACK | + +### Setup +```pseudo +captured_messages = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + captured_messages.append(msg) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, ["serial-0"])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +result = AWAIT channel.object.publish([ + build_counter_inc("counter:score@1000", 5, null, null) +]) +``` + +### Assertions +```pseudo +ASSERT captured_messages.length == 1 +ASSERT captured_messages[0].action == OBJECT +ASSERT captured_messages[0].channel == "test" +ASSERT captured_messages[0].state.length == 1 +ASSERT result.serials == ["serial-0"] +``` + +--- + +## RTO20 - publishAndApply applies locally on ACK + +**Test ID**: `objects/unit/RTO20/publish-and-apply-local-0` + +| Spec | Requirement | +|------|-------------| +| RTO20b | Calls publish and awaits PublishResult | +| RTO20d2a | Synthetic message serial from PublishResult | +| RTO20d2b | Synthetic message siteCode from ConnectionDetails | +| RTO20f | Apply synthetic messages with source LOCAL | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO20c - publishAndApply logs error when siteCode missing + +**Test ID**: `objects/unit/RTO20c/missing-site-code-0` + +| Spec | Requirement | +|------|-------------| +| RTO20c1 | Requires siteCode from ConnectionDetails | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, ["serial-0"])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20d1 - null serial in PublishResult is skipped + +**Test ID**: `objects/unit/RTO20d1/null-serial-skipped-0` + +**Spec requirement:** If serial from PublishResult is null, skip that ObjectMessage. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + mock_ws.send_to_client(build_ack_message(msg.msgSerial, [null])) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20e - publishAndApply waits for SYNCED during SYNCING + +**Test ID**: `objects/unit/RTO20e/waits-for-synced-0` + +**Spec requirement:** If sync state is not SYNCED, wait for SYNCED transition. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +inc_future = root.increment(10) + +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +AWAIT inc_future +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO20e1 - publishAndApply fails when channel enters FAILED during sync wait + +**Test ID**: `objects/unit/RTO20e1/fails-on-channel-failed-0` + +**Spec requirement:** If channel enters DETACHED/SUSPENDED/FAILED while waiting, fail with 92008. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", + flags: HAS_OBJECTS +)) + +inc_future = root.increment(10) + +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel detached" } +)) + +AWAIT inc_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92008 +``` + +--- + +## RTO17, RTO18 - Sync state events + +**Test ID**: `objects/unit/RTO17/sync-state-events-0` + +| Spec | Requirement | +|------|-------------| +| RTO17b | Emit event matching new sync state | +| RTO18b1 | SYNCING event | +| RTO18b2 | SYNCED event | +| RTO18e | Listeners called with no arguments | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:cursor", + flags: HAS_OBJECTS + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) + +events = [] +channel.object.on(SYNCING, () => events.append("SYNCING")) +channel.object.on(SYNCED, () => events.append("SYNCED")) +``` + +### Test Steps +```pseudo +get_future = channel.object.get() + +poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_sync_message("test", "sync1:", STANDARD_POOL_OBJECTS)) + +AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT events CONTAINS_IN_ORDER ["SYNCING", "SYNCED"] +``` + +--- + +## RTO18d - Duplicate listener registered twice fires twice + +**Test ID**: `objects/unit/RTO18d/duplicate-listener-0` + +**Spec requirement:** If same listener registered twice, it is invoked twice per event. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +call_count = 0 +listener = () => { call_count++ } +channel.object.on(SYNCED, listener) +channel.object.on(SYNCED, listener) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +poll_until(call_count >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT call_count == 2 +``` + +--- + +## RTO19 - off() deregisters listener + +**Test ID**: `objects/unit/RTO19/off-deregisters-0` + +**Spec requirement:** Deregisters event listener previously registered via on(). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +call_count = 0 +listener = () => { call_count++ } +sub = channel.object.on(SYNCED, listener) +sub.off() +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) +``` + +### Assertions +```pseudo +ASSERT call_count == 0 +``` + +--- + +## RTO2 - Channel mode enforcement + +**Test ID**: `objects/unit/RTO2/mode-enforcement-0` + +| Spec | Requirement | +|------|-------------| +| RTO2a | ATTACHED state checks granted modes | +| RTO2b | Non-ATTACHED checks requested modes | +| RTO2a2 | Missing mode throws 40024 | + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, + modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +``` + +--- + +## RTO10 - GC removes tombstoned objects past grace period + +**Test ID**: `objects/unit/RTO10/gc-tombstoned-objects-0` + +| Spec | Requirement | +|------|-------------| +| RTO10a | Check at regular intervals | +| RTO10c1b | Remove if difference >= grace period | +| RTO10b1 | Grace period from ConnectionDetails | + +### Setup +```pseudo +enable_fake_timers() +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "99", "site1", 1000) +])) +``` + +### Test Steps +```pseudo +ADVANCE_TIME(86400000 + 300000) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == null +``` + +--- + +## RTO20 - Echo deduplication via appliedOnAckSerials + +**Test ID**: `objects/unit/RTO20/echo-dedup-0` + +**Spec requirement:** When echo arrives with same serial as applied-on-ACK, it is deduplicated. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +score_after_apply = root.get("score").value() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "ack-0:0", "test-site") +])) +score_after_echo = root.get("score").value() +``` + +### Assertions +```pseudo +ASSERT score_after_apply == 110 +ASSERT score_after_echo == 110 +``` + +--- + +## RTO20f - Apply-on-ACK does not update siteTimeserials + +**Test ID**: `objects/unit/RTO20f/ack-no-site-timeserials-update-0` + +| Spec | Requirement | +|------|-------------| +| RTO20f | Apply with source LOCAL | +| RTLC7c2 | LOCAL source does not update siteTimeserials | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +site_serials_before = root.get("score").instance()._liveObject.siteTimeserials +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +site_serials_after = root.get("score").instance()._liveObject.siteTimeserials +``` + +### Assertions +```pseudo +ASSERT site_serials_after == site_serials_before +``` + +--- + +## RTO20 - ACK after echo does not double-apply + +**Test ID**: `objects/unit/RTO20/ack-after-echo-no-double-apply-0` + +**Spec requirement:** If the echo arrives before the ACK is processed, the ACK-based apply finds the serial already applied and deduplicates via RTO9a3. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel_no_ack("test") +``` + +### Test Steps +```pseudo +inc_future = root.increment(10) + +// Send the echo BEFORE the ACK +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "ack-0:0", "test-site") +])) + +// Now send the ACK +mock_ws.send_to_client(build_ack_message(0, ["ack-0:0"])) + +AWAIT inc_future +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO5c9, RTO20 - appliedOnAckSerials cleared on re-sync + +**Test ID**: `objects/unit/RTO5c9-RTO20/ack-serials-cleared-on-resync-0` + +**Spec requirement:** appliedOnAckSerials is cleared when sync completes. After re-sync, an echo with a previously-applied serial is applied normally (not deduplicated). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +ASSERT root.get("score").value() == 110 + +// Trigger re-sync +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + +// After re-sync, the score is back to 100 (from pool state) +ASSERT root.get("score").value() == 100 +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 100 +``` + +--- + +## RTO20 - Subscription fires on apply-on-ACK + +**Test ID**: `objects/unit/RTO20/subscription-fires-on-ack-apply-0` + +**Spec requirement:** When publishAndApply applies locally via ACK, subscription listeners are notified. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.get("score").subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +AWAIT root.increment(10) +``` + +### Assertions +```pseudo +ASSERT events.length >= 1 +ASSERT root.get("score").value() == 110 +``` + +--- + +## RTO23 - get() implicitly attaches channel + +**Test ID**: `objects/unit/RTO23/get-implicit-attach-0` + +**Spec requirement:** get() triggers attach if channel is not yet attached. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +ASSERT channel.state == INITIALIZED +root = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root IS PathObject +ASSERT channel.state == ATTACHED +``` + +--- + +## RTO23d - get() resolves immediately when already SYNCED + +**Test ID**: `objects/unit/RTO23d/get-resolves-immediately-synced-0` + +**Spec requirement:** If sync state is already SYNCED, get() resolves immediately. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root2 = AWAIT channel.object.get() +``` + +### Assertions +```pseudo +ASSERT root2 IS PathObject +ASSERT root2.path() == "" +``` + +--- + +## RTO10b1 - GC grace period from ConnectionDetails + +**Test ID**: `objects/unit/RTO10b1/gc-grace-period-source-0` + +**Spec requirement:** GC grace period comes from ConnectionDetails.objectsGCGracePeriod. + +### Setup +```pseudo +enable_fake_timers() +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 5000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == OBJECT: + serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) + mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() + +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "99", "site1", 1000) +])) +``` + +### Test Steps +```pseudo +// Short grace period (5000ms) — advance past it +ADVANCE_TIME(5000 + 1000) +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == null +``` + +--- + +## RTO17, RTO18 - Sync event sequences for all state transitions + +**Test ID**: `objects/unit/RTO17-RTO18/sync-event-sequences-0` + +**Spec requirement:** Verify all sync state transition sequences. + +### Setup +```pseudo +scenarios = [ + { + name: "initial attach", + trigger: () => { + channel.attach() + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "re-attach after detach", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: "test")) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync2:", STANDARD_POOL_OBJECTS)) + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "re-sync on new ATTACHED", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync3:cursor", flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message("test", "sync3:", STANDARD_POOL_OBJECTS)) + }, + expected_events: ["SYNCING", "SYNCED"] + }, + { + name: "ATTACHED without HAS_OBJECTS", + trigger: () => { + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync4:", flags: 0 + )) + }, + expected_events: ["SYNCED"] + } +] + +FOR scenario IN scenarios: + { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + events = [] + channel.object.on(SYNCING, () => events.append("SYNCING")) + channel.object.on(SYNCED, () => events.append("SYNCED")) + + scenario.trigger() + poll_until(events.length >= scenario.expected_events.length, timeout: 5s) + + ASSERT events == scenario.expected_events +``` diff --git a/uts/objects/unit/value_types.md b/uts/objects/unit/value_types.md new file mode 100644 index 000000000..dc99aec26 --- /dev/null +++ b/uts/objects/unit/value_types.md @@ -0,0 +1,451 @@ +# Value Types Tests + +Spec points: `RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4` + +## Test Type +Unit test — pure construction and consumption, no mocks required. + +## Purpose + +Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When consumed by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). + +--- + +## RTLCV3 - LiveCounter.create with initial count + +**Test ID**: `objects/unit/RTLCV3/create-with-count-0` + +| Spec | Requirement | +|------|-------------| +| RTLCV3a1 | Accepts optional initialCount | +| RTLCV3b | Returns LiveCounterValueType with internal count | +| RTLCV3d | Returned value is immutable | + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +ASSERT vt.count == 42 +``` + +--- + +## RTLCV3 - LiveCounter.create defaults to 0 + +**Test ID**: `objects/unit/RTLCV3/create-default-zero-0` + +**Spec requirement:** If initialCount omitted, defaults to 0. + +### Test Steps +```pseudo +vt = LiveCounter.create() +``` + +### Assertions +```pseudo +ASSERT vt.count == 0 +``` + +--- + +## RTLCV3c - No validation at creation time + +**Test ID**: `objects/unit/RTLCV3c/no-validation-at-create-0` + +**Spec requirement:** No input validation is performed at creation time; deferred to consumption. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +``` + +--- + +## RTLCV4 - Consumption generates COUNTER_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLCV4/consume-generates-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLCV4b1 | CounterCreate.count set to internal count | +| RTLCV4c | Initial value JSON string from CounterCreate | +| RTLCV4d | Unique nonce with 16+ characters | +| RTLCV4f | objectId generated via RTO14 with type "counter" | +| RTLCV4g1 | action set to COUNTER_CREATE | +| RTLCV4g2 | objectId set | +| RTLCV4g3 | counterCreateWithObjectId.nonce set | +| RTLCV4g4 | counterCreateWithObjectId.initialValue set | + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = consume(vt) +``` + +### Assertions +```pseudo +ASSERT messages.length == 1 +msg = messages[0] +ASSERT msg.operation.action == "COUNTER_CREATE" +ASSERT msg.operation.objectId STARTS WITH "counter:" +ASSERT msg.operation.objectId CONTAINS "@" +ASSERT msg.operation.counterCreateWithObjectId IS NOT null +ASSERT msg.operation.counterCreateWithObjectId.nonce IS NOT null +ASSERT msg.operation.counterCreateWithObjectId.nonce.length >= 16 +ASSERT msg.operation.counterCreateWithObjectId.initialValue IS NOT null +``` + +--- + +## RTLCV4g5 - Consumption retains local CounterCreate + +**Test ID**: `objects/unit/RTLCV4g5/retains-local-counter-create-0` + +**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use. + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate IS NOT null +ASSERT msg.operation.counterCreate.count == 42 +``` + +--- + +## RTLCV4a - Consumption validates count type + +**Test ID**: `objects/unit/RTLCV4a/consume-validates-count-0` + +**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLCV4 - Consumption with count 0 + +**Test ID**: `objects/unit/RTLCV4/consume-zero-count-0` + +**Spec requirement:** count=0 is valid and should be included in CounterCreate. + +### Test Steps +```pseudo +vt = LiveCounter.create(0) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate.count == 0 +``` + +--- + +## RTLMV3 - LiveMap.create with entries + +**Test ID**: `objects/unit/RTLMV3/create-with-entries-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV3a1 | Accepts optional entries dict | +| RTLMV3b | Returns LiveMapValueType with internal entries | +| RTLMV3d | Returned value is immutable | + +### Test Steps +```pseudo +vt = LiveMap.create({ + "name": "Alice", + "age": 30 +}) +``` + +### Assertions +```pseudo +ASSERT vt IS LiveMapValueType +ASSERT vt.entries["name"] == "Alice" +ASSERT vt.entries["age"] == 30 +``` + +--- + +## RTLMV3 - LiveMap.create with no entries + +**Test ID**: `objects/unit/RTLMV3/create-no-entries-0` + +**Spec requirement:** If entries omitted, internal entries is undefined. + +### Test Steps +```pseudo +vt = LiveMap.create() +``` + +### Assertions +```pseudo +ASSERT vt IS LiveMapValueType +``` + +--- + +## RTLMV4 - Consumption generates MAP_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLMV4/consume-generates-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4e1 | MapCreate.semantics set to LWW | +| RTLMV4f | Initial value JSON string | +| RTLMV4g | Unique nonce 16+ chars | +| RTLMV4i | objectId via RTO14 with type "map" | +| RTLMV4j1 | action set to MAP_CREATE | +| RTLMV4j3 | mapCreateWithObjectId.nonce set | +| RTLMV4j4 | mapCreateWithObjectId.initialValue set | + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = consume(vt) +``` + +### Assertions +```pseudo +ASSERT messages.length == 1 +msg = messages[0] +ASSERT msg.operation.action == "MAP_CREATE" +ASSERT msg.operation.objectId STARTS WITH "map:" +ASSERT msg.operation.mapCreateWithObjectId IS NOT null +ASSERT msg.operation.mapCreateWithObjectId.nonce.length >= 16 +ASSERT msg.operation.mapCreateWithObjectId.initialValue IS NOT null +``` + +--- + +## RTLMV4j5 - Consumption retains local MapCreate + +**Test ID**: `objects/unit/RTLMV4j5/retains-local-map-create-0` + +**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use. + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.mapCreate IS NOT null +ASSERT msg.operation.mapCreate.semantics == "LWW" +ASSERT msg.operation.mapCreate.entries["name"].data.string == "Alice" +``` + +--- + +## RTLMV4d - Entry value type mapping + +**Test ID**: `objects/unit/RTLMV4d/entry-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4d3 | JsonArray/JsonObject -> data.json | +| RTLMV4d4 | String -> data.string | +| RTLMV4d5 | Number -> data.number | +| RTLMV4d6 | Boolean -> data.boolean | +| RTLMV4d7 | Binary -> data.bytes | + +### Test Steps +```pseudo +vt = LiveMap.create({ + "str": "hello", + "num": 42, + "bool": true, + "json_arr": [1, 2, 3], + "json_obj": { "key": "value" } +}) +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +entries = msg.operation.mapCreate.entries +ASSERT entries["str"].data.string == "hello" +ASSERT entries["num"].data.number == 42 +ASSERT entries["bool"].data.boolean == true +ASSERT entries["json_arr"].data.json == [1, 2, 3] +ASSERT entries["json_obj"].data.json == { "key": "value" } +``` + +--- + +## RTLMV4d1, RTLMV4d2 - Nested value types produce depth-first ObjectMessages + +**Test ID**: `objects/unit/RTLMV4d1/nested-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLMV4d1 | LiveCounterValueType consumed, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMapValueType recursively consumed, all ObjectMessages collected | +| RTLMV4k | Return depth-first order: inner creates before outer | + +### Test Steps +```pseudo +inner_counter = LiveCounter.create(10) +inner_map = LiveMap.create({ + "nested_count": inner_counter +}) +outer = LiveMap.create({ + "child": inner_map +}) +messages = consume(outer) +``` + +### Assertions +```pseudo +ASSERT messages.length == 3 +ASSERT messages[0].operation.action == "COUNTER_CREATE" +ASSERT messages[0].operation.objectId STARTS WITH "counter:" +ASSERT messages[1].operation.action == "MAP_CREATE" +ASSERT messages[1].operation.objectId STARTS WITH "map:" +ASSERT messages[2].operation.action == "MAP_CREATE" +ASSERT messages[2].operation.objectId STARTS WITH "map:" + +inner_counter_id = messages[0].operation.objectId +inner_map_id = messages[1].operation.objectId +outer_map_id = messages[2].operation.objectId + +ASSERT messages[1].operation.mapCreate.entries["nested_count"].data.objectId == inner_counter_id +ASSERT messages[2].operation.mapCreate.entries["child"].data.objectId == inner_map_id +``` + +--- + +## RTLMV4a - Consumption validates entries type + +**Test ID**: `objects/unit/RTLMV4a/consume-validates-entries-0` + +**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003. + +### Test Steps +```pseudo +vt = LiveMap.create(null) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4b - Consumption validates key types + +**Test ID**: `objects/unit/RTLMV4b/consume-validates-keys-0` + +**Spec requirement:** If any key is not String, throw 40003. + +### Test Steps +```pseudo +vt = LiveMap.create({ 123: "value" }) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4c - Consumption validates value types + +**Test ID**: `objects/unit/RTLMV4c/consume-validates-values-0` + +**Spec requirement:** If any value is not an expected type, throw 40013. + +### Test Steps +```pseudo +vt = LiveMap.create({ "fn": some_function }) +consume(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40013 +``` + +--- + +## RTLMV4e2 - Empty entries produces MapCreate with empty entries + +**Test ID**: `objects/unit/RTLMV4e2/empty-entries-0` + +**Spec requirement:** If internal entries is undefined, MapCreate.entries is empty map. + +### Test Steps +```pseudo +vt = LiveMap.create() +messages = consume(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.mapCreate.entries == {} +``` + +--- + +## RTLMV4d - Table-driven MAP_SET value type mapping + +**Test ID**: `objects/unit/RTLMV4d/map-set-all-types-table-0` + +**Spec requirement:** Every supported value type maps to the correct data field. + +### Test Steps +```pseudo +type_scenarios = [ + { input: "hello", expected_field: "string", expected_value: "hello" }, + { input: 42, expected_field: "number", expected_value: 42 }, + { input: 3.14, expected_field: "number", expected_value: 3.14 }, + { input: 0, expected_field: "number", expected_value: 0 }, + { input: -1, expected_field: "number", expected_value: -1 }, + { input: true, expected_field: "boolean", expected_value: true }, + { input: false, expected_field: "boolean", expected_value: false }, + { input: [1, "a", null], expected_field: "json", expected_value: [1, "a", null] }, + { input: { "k": "v" }, expected_field: "json", expected_value: { "k": "v" } }, + { input: bytes([1, 2, 3]), expected_field: "bytes", expected_value: "AQID" } +] + +FOR scenario IN type_scenarios: + vt = LiveMap.create({ "test_key": scenario.input }) + messages = consume(vt) + entry = messages[0].operation.mapCreate.entries["test_key"] + ASSERT entry.data[scenario.expected_field] == scenario.expected_value +``` From 828b8e19f8d4846fbae56a37a09575afab317d1c Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Thu, 14 May 2026 08:16:21 +0100 Subject: [PATCH 43/44] Delegate proxy port assignment to uts-proxy in all test specs Remove client-side allocated_port/port_base patterns from all proxy test specs and helper docs. Port is now auto-assigned by the proxy when omitted from create_proxy_session(). Matches uts-proxy v0.2.0. Co-Authored-By: Claude Opus 4.6 --- uts/docs/integration-testing.md | 1 - uts/docs/writing-test-specs.md | 1 - .../integration/proxy/objects_faults.md | 10 ++--- uts/realtime/integration/helpers/proxy.md | 5 +-- uts/realtime/integration/proxy/auth_reauth.md | 13 +----- .../integration/proxy/channel_faults.md | 14 +++--- .../proxy/connection_open_failures.md | 10 ++--- .../integration/proxy/connection_resume.md | 44 +++++-------------- uts/realtime/integration/proxy/heartbeat.md | 2 +- uts/realtime/integration/proxy/rest_faults.md | 6 +-- uts/rest/integration/proxy/rest_fallback.md | 14 +++--- 11 files changed, 42 insertions(+), 78 deletions(-) diff --git a/uts/docs/integration-testing.md b/uts/docs/integration-testing.md index fa7fd0a6c..cca26a715 100644 --- a/uts/docs/integration-testing.md +++ b/uts/docs/integration-testing.md @@ -157,7 +157,6 @@ Proxy tests additionally set up a proxy session per test or group of tests. See BEFORE EACH TEST: session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, rules: [ ...initial rules... ] ) diff --git a/uts/docs/writing-test-specs.md b/uts/docs/writing-test-specs.md index 1102181a8..4abce6575 100644 --- a/uts/docs/writing-test-specs.md +++ b/uts/docs/writing-test-specs.md @@ -344,7 +344,6 @@ Tests that [behaviour] when the proxy injects [fault]. ```pseudo session = create_proxy_session( target: TargetConfig(realtimeHost: "sandbox.realtime.ably-nonprod.net", restHost: "sandbox.realtime.ably-nonprod.net"), - port: allocated_port, rules: [{ "match": { ... }, "action": { ... }, diff --git a/uts/objects/integration/proxy/objects_faults.md b/uts/objects/integration/proxy/objects_faults.md index 24069b737..8988a0191 100644 --- a/uts/objects/integration/proxy/objects_faults.md +++ b/uts/objects/integration/proxy/objects_faults.md @@ -80,7 +80,7 @@ channel_name = "objects-sync-interrupt-" + random_id() // Disconnect after first OBJECT_SYNC frame session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": 20 }, "action": { "type": "disconnect" }, @@ -163,7 +163,7 @@ AWAIT root_a.set("key1", "initial") // Client B: through proxy, will be disconnected session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -242,7 +242,7 @@ channel_name = "objects-detach-resync-" + random_id() session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -318,7 +318,7 @@ channel_name = "objects-publish-failed-" + random_id() session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -403,7 +403,7 @@ AWAIT root_a.set("existing", "before") // Client B: through proxy with delayed OBJECT_SYNC session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": 20 }, "action": { "type": "delay", "delayMs": 3000 }, diff --git a/uts/realtime/integration/helpers/proxy.md b/uts/realtime/integration/helpers/proxy.md index 18274311e..303d8ea74 100644 --- a/uts/realtime/integration/helpers/proxy.md +++ b/uts/realtime/integration/helpers/proxy.md @@ -20,7 +20,6 @@ Proxy integration tests use this to verify fault-handling behaviour against the # 1. Create a proxy session with rules session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, rules: [ ...rules... ] ) @@ -58,7 +57,7 @@ session.close() interface ProxySession: session_id: String proxy_host: String # Always "localhost" - proxy_port: Int # Assigned from port pool + proxy_port: Int # Auto-assigned by proxy, or explicit if specified add_rules(rules: List, position?: "append"|"prepend") trigger_action(action: ActionRequest) @@ -67,7 +66,7 @@ interface ProxySession: function create_proxy_session( endpoint: String, # e.g. "nonprod:sandbox" → resolves to sandbox.realtime.ably-nonprod.net - port: Int, + port?: Int, # Optional; proxy auto-assigns a free port if omitted rules?: List, timeoutMs?: Int # Session auto-cleanup timeout (default 30000) ): ProxySession diff --git a/uts/realtime/integration/proxy/auth_reauth.md b/uts/realtime/integration/proxy/auth_reauth.md index 6d704c966..edb4d9894 100644 --- a/uts/realtime/integration/proxy/auth_reauth.md +++ b/uts/realtime/integration/proxy/auth_reauth.md @@ -31,16 +31,6 @@ AFTER ALL TESTS: WITH Authorization: Basic {api_key} ``` -## Port Allocation - -Each test allocates a unique proxy port to avoid conflicts: - -```pseudo -BEFORE ALL TESTS: - port_base = allocate_port_range(count: 1) - # Tests use port_base + 0 -``` - --- ## Test 26: RTN22/RTC8a -- Server-initiated re-authentication @@ -63,7 +53,6 @@ Tests that when the proxy injects a server-initiated AUTH ProtocolMessage (actio ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [] ) ``` @@ -83,7 +72,7 @@ auth_callback = FUNCTION(params, callback): client = Realtime(options: ClientOptions( authCallback: auth_callback, endpoint: "localhost", - port: port_base + 0, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false diff --git a/uts/realtime/integration/proxy/channel_faults.md b/uts/realtime/integration/proxy/channel_faults.md index 1035d6772..35b2da0fd 100644 --- a/uts/realtime/integration/proxy/channel_faults.md +++ b/uts/realtime/integration/proxy/channel_faults.md @@ -71,7 +71,7 @@ channel_name = "test-RTL4f-${random_id()}" # Create proxy session that suppresses ATTACH messages for our channel session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_server", "action": "ATTACH", "channel": channel_name }, "action": { "type": "suppress" }, @@ -177,7 +177,7 @@ channel_name = "test-RTL14-error-on-attach-${random_id()}" # Create proxy session that replaces ATTACHED with channel ERROR session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "ATTACHED", "channel": channel_name }, "action": { @@ -274,7 +274,7 @@ channel_name = "test-RTL5f-${random_id()}" # Phase 1: Create proxy session with NO fault rules (clean passthrough) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -374,7 +374,7 @@ channel_name = "test-RTL13a-${random_id()}" # Create proxy session with clean passthrough (no fault rules initially) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -471,7 +471,7 @@ channel_name = "test-RTL14-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -563,7 +563,7 @@ channel_name = "test-RTL12-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) @@ -666,7 +666,7 @@ channel_b_name = "test-RTL3d-b-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) diff --git a/uts/realtime/integration/proxy/connection_open_failures.md b/uts/realtime/integration/proxy/connection_open_failures.md index 44b65ae79..0c79673db 100644 --- a/uts/realtime/integration/proxy/connection_open_failures.md +++ b/uts/realtime/integration/proxy/connection_open_failures.md @@ -66,7 +66,7 @@ Tests that when the server responds with a fatal ERROR (non-token error code) du # Create proxy session that replaces the first CONNECTED with a fatal ERROR session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -151,7 +151,7 @@ auth_callback_count = 0 # Create proxy session that injects token error on first CONNECTED only session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -246,7 +246,7 @@ Tests that when the first WebSocket connection is refused at the transport level # Create proxy session that refuses the first WebSocket connection session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_connect", "count": 1 }, "action": { "type": "refuse_connection" }, @@ -325,7 +325,7 @@ Tests that when the server responds with a connection-level ERROR (no channel fi # Create proxy session that replaces the first CONNECTED with a server ERROR session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { @@ -408,7 +408,7 @@ Tests that when the server accepts the WebSocket but never sends a CONNECTED mes # Create proxy session that suppresses all CONNECTED messages session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, "action": { "type": "suppress" }, diff --git a/uts/realtime/integration/proxy/connection_resume.md b/uts/realtime/integration/proxy/connection_resume.md index 3c908366a..1621137a1 100644 --- a/uts/realtime/integration/proxy/connection_resume.md +++ b/uts/realtime/integration/proxy/connection_resume.md @@ -28,16 +28,6 @@ AFTER ALL TESTS: WITH Authorization: Basic {api_key} ``` -## Port Allocation - -Each test allocates a unique proxy port to avoid conflicts: - -```pseudo -BEFORE ALL TESTS: - port_base = allocate_port_range(count: 11) - # Tests use port_base + 0 through port_base + 10 -``` - --- ## Test 6: RTN15a - Unexpected disconnect triggers resume @@ -59,7 +49,6 @@ Tests that an unexpected transport disconnect causes the SDK to reconnect and at ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -79,7 +68,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 0, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -163,7 +152,6 @@ frame) after a 1-second delay. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 0, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -211,7 +199,6 @@ Tests that after an unexpected disconnect and successful resume, the connection ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 1, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -231,7 +218,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 1, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -309,7 +296,6 @@ Tests that when a resume fails (simulated by the proxy replacing the server's se ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 2, rules: [ { match: { type: "delay_after_ws_connect", delayMs: 1000 }, @@ -358,7 +344,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 2, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -442,7 +428,6 @@ Tests that when the proxy injects a DISCONNECTED message with a token error (cod ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 3, rules: [ { "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, @@ -479,7 +464,7 @@ token_string = token_details.token client = Realtime(options: ClientOptions( token: token_string, endpoint: "localhost", - port: port_base + 3, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -553,7 +538,6 @@ Tests that when the proxy injects a DISCONNECTED message with a non-token error ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 4, rules: [ { "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, @@ -583,7 +567,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 4, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -669,7 +653,6 @@ Tests that a connection-level ERROR ProtocolMessage (no channel field) causes th ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 5, rules: [] ) ``` @@ -682,7 +665,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 5, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -799,7 +782,6 @@ Tests that when the client has been disconnected for longer than connectionState ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 6, rules: [ { "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, @@ -849,7 +831,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 6, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, @@ -932,7 +914,6 @@ Tests that a message awaiting ACK on the old transport is resent after reconnect ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 7, rules: [ { "match": { "type": "ws_frame_to_client", "action": "ACK" }, @@ -954,7 +935,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 7, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -1067,7 +1048,6 @@ Use a direct proxy session (passthrough, no rules) to connect to the sandbox, at ```pseudo session_1 = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 8, rules: [] ) @@ -1076,7 +1056,7 @@ client_1 = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 8, + port: session_1.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false @@ -1090,7 +1070,6 @@ A second proxy session is used so we can inspect the `recover` query parameter i ```pseudo session_2 = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 9, rules: [] ) ``` @@ -1140,7 +1119,7 @@ client_2 = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 9, + port: session_2.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, @@ -1206,7 +1185,6 @@ Tests that when a recovery attempt fails (the server responds with a new connect ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: port_base + 10, rules: [ { "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, @@ -1257,7 +1235,7 @@ client = Realtime(options: ClientOptions( RETURN generate_jwt(keyName: key_name, keySecret: key_secret) }, endpoint: "localhost", - port: port_base + 10, + port: session.proxy_port, tls: false, useBinaryProtocol: false, autoConnect: false, diff --git a/uts/realtime/integration/proxy/heartbeat.md b/uts/realtime/integration/proxy/heartbeat.md index 8f6e1e2b3..e213436b7 100644 --- a/uts/realtime/integration/proxy/heartbeat.md +++ b/uts/realtime/integration/proxy/heartbeat.md @@ -66,7 +66,7 @@ The proxy closes the WebSocket connection after a 2s delay from ws_connect, simu # Create proxy session that closes the WebSocket after 2s to simulate transport failure session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "delay_after_ws_connect", "delayMs": 2000 }, "action": { "type": "close" }, diff --git a/uts/realtime/integration/proxy/rest_faults.md b/uts/realtime/integration/proxy/rest_faults.md index 7fdeb4592..e93ce3cf9 100644 --- a/uts/realtime/integration/proxy/rest_faults.md +++ b/uts/realtime/integration/proxy/rest_faults.md @@ -89,7 +89,7 @@ auth_callback_count = 0 # Create proxy session that returns 401 on the first channel request session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/channels/" }, "action": { @@ -175,7 +175,7 @@ Tests that when a REST request receives an HTTP 503 (Service Unavailable) and th # Create proxy session that returns 503 on the first channel request session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/channels/" }, "action": { @@ -251,7 +251,7 @@ Tests that the proxy transparently forwards both WebSocket and HTTP traffic with # Create proxy session with no rules (pure passthrough) session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [] ) diff --git a/uts/rest/integration/proxy/rest_fallback.md b/uts/rest/integration/proxy/rest_fallback.md index bd3b13232..51ffc5261 100644 --- a/uts/rest/integration/proxy/rest_fallback.md +++ b/uts/rest/integration/proxy/rest_fallback.md @@ -100,7 +100,7 @@ a fallback host (also routed through the proxy) and succeeds. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -160,7 +160,7 @@ fallback host. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -273,7 +273,7 @@ on the retry. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -328,7 +328,7 @@ are configured, so the error propagates directly to the caller. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -382,7 +382,7 @@ non-parseable body while still returning valid JSON. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -435,7 +435,7 @@ should trigger fallback; 4xx errors indicate a client-side problem. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "pathContains": "/time" }, "action": { @@ -502,7 +502,7 @@ on the library-generated message `id`. ```pseudo session = create_proxy_session( endpoint: "nonprod:sandbox", - port: allocated_port, + rules: [{ "match": { "type": "http_request", "method": "POST", "pathContains": "/channels/" }, "action": { From 97d02db046198cf2292180b5667cdf6e035e4009 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Wed, 27 May 2026 22:52:50 +0100 Subject: [PATCH 44/44] Update UTS test specs to match LiveObjects path-based API spec (a397e34) Align all ~330 LiveObjects UTS test specs with the squashed spec revision a397e34 (LiveObjects path-based API). Key changes: - Add parent_references.md (20 tests): RTLO3f, RTLO4g/4h, RTLO4f, RTO5c10 - Add public_object_message.md (13 tests): PAOM1-3, PAOOP1-3 - Thread ObjectMessage through all CRDT operations and LiveObjectUpdate - Add RTO25 (access preconditions) and RTO26 (write preconditions) - Update subscription model: subscribe returns Subscription object - Add RTO24 (PathObjectSubscriptionRegister) dispatch tests - Add parentReferences maintenance tests to live_map.md (+8 tests) - Add post-sync parentReferences rebuild tests to objects_pool.md (+3) - Rename "consume"/"consumption" to "evaluate"/"evaluation" in value_types - Remove batch.md (Batch API deferred from current spec revision) - Remove subscribeIterator and LiveObject#unsubscribe tests - Update PLAN.md to reflect new file structure and test counts Co-Authored-By: Claude Opus 4.6 --- uts/objects/PLAN.md | 126 ++-- uts/objects/helpers/standard_test_pool.md | 45 ++ uts/objects/unit/batch.md | 782 ---------------------- uts/objects/unit/instance.md | 230 ++++--- uts/objects/unit/live_counter.md | 31 +- uts/objects/unit/live_counter_api.md | 92 +-- uts/objects/unit/live_map.md | 414 +++++++++++- uts/objects/unit/live_map_api.md | 143 ++-- uts/objects/unit/live_object_subscribe.md | 250 +++++-- uts/objects/unit/objects_pool.md | 225 ++++++- uts/objects/unit/parent_references.md | 734 ++++++++++++++++++++ uts/objects/unit/path_object.md | 206 +++++- uts/objects/unit/path_object_mutations.md | 67 +- uts/objects/unit/path_object_subscribe.md | 514 +++++++++----- uts/objects/unit/public_object_message.md | 555 +++++++++++++++ uts/objects/unit/realtime_object.md | 444 +++++++++++- uts/objects/unit/value_types.md | 80 +-- 17 files changed, 3552 insertions(+), 1386 deletions(-) delete mode 100644 uts/objects/unit/batch.md create mode 100644 uts/objects/unit/parent_references.md create mode 100644 uts/objects/unit/public_object_message.md diff --git a/uts/objects/PLAN.md b/uts/objects/PLAN.md index 3cc547856..a95734829 100644 --- a/uts/objects/PLAN.md +++ b/uts/objects/PLAN.md @@ -2,7 +2,7 @@ ## Context -The LiveObjects feature lets clients store shared CRDT data on realtime channels. The specification is at `specification/specifications/objects-features.md` — specifically the path-based API version on branch `origin/AIT-30/liveobjects-path-based-api-spec` (with batch API additions on `origin/AIT-30/liveobjects-batch-api`). +The LiveObjects feature lets clients store shared CRDT data on realtime channels. The specification is at `specification/specifications/objects-features.md` — the path-based API version squashed as commit `a397e34` ("LiveObjects path-based API spec"). An earlier attempt at UTS test specs exists in `uts/test/realtime/unit/objects/` (14 files). It was written against a different spec namespace (PO* vs RTPO*/RTINS*/RTLCV*/RTLMV*), used v5 wire format field names, had apply-on-ACK contradictions, and duplicated setup across files. We're doing a clean rewrite using the correct spec, informed by that earlier work. @@ -12,7 +12,7 @@ All new test files go in `specification/uts/objects/`. **Internal (not user-facing):** LiveObject, LiveCounter (CRDT counter), LiveMap (LWW map), ObjectsPool (sync state machine), RealtimeObject (channel orchestrator with publishAndApply) -**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounterValueType/LiveMapValueType (creation descriptors via static `create()` factories), BatchContext (atomic multi-op publish) +**Public (user-facing):** PathObject (lazy path reference), Instance (identity-bound reference), LiveCounterValueType/LiveMapValueType (creation descriptors via static `create()` factories), PublicAPI::ObjectMessage/ObjectOperation (user-facing event metadata) **Wire protocol v6:** `counterInc.number`, `mapSet.{key,value}`, `mapRemove.key`, `mapCreate.{semantics,entries}`, `counterCreateWithObjectId.{nonce,initialValue}`, `mapCreateWithObjectId.{nonce,initialValue}` @@ -30,31 +30,32 @@ All new test files go in `specification/uts/objects/`. ### Pure Unit Tests (no mocks) | File | Spec Points | ~Tests | |------|-------------|--------| -| `unit/live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~28 | -| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3, RTLO4a, RTLO4e, RTLO5, RTLO6 | ~42 | -| `unit/objects_pool.md` | RTO3-9 | ~35 | +| `unit/live_counter.md` | RTLC1-4, RTLC6-9, RTLC14, RTLC16, RTLO3-6, RTLO4b4d-e | ~23 | +| `unit/live_map.md` | RTLM1-9, RTLM14-16, RTLM18-19, RTLM22-25, RTLO3-6, RTLO4g-h, RTLO4e9 | ~38 | +| `unit/objects_pool.md` | RTO3-9, RTO5c10 | ~28 | | `unit/object_id.md` | RTO14 | ~5 | -| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (consumption generates ObjectMessages with v6 wire format) | ~19 | +| `unit/value_types.md` | RTLCV1-4, RTLMV1-4 (evaluation generates ObjectMessages with v6 wire format) | ~19 | +| `unit/parent_references.md` | RTLO3f, RTLO4f-h, RTO5c10 (parentReferences, getFullPaths, add/remove/rebuild) | ~20 | +| `unit/public_object_message.md` | PAOM1-3, PAOOP1-3 (PublicAPI::ObjectMessage/ObjectOperation construction) | ~13 | ### Mock WebSocket Unit Tests | File | Spec Points | ~Tests | |------|-------------|--------| -| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-24 (sync events, publish, publishAndApply, mode checks, GC) | ~33 | +| `unit/realtime_object.md` | RTO2, RTO10, RTO15-20, RTO22-26 (sync events, publish, publishAndApply, GC, RTO24/25/26 preconditions) | ~36 | | `unit/live_counter_api.md` | RTLC5, RTLC11-13 (value, increment, decrement through channel) | ~13 | -| `unit/live_map_api.md` | RTLM5, RTLM10-13, RTLM20-21, RTLM24 (reads + mutations through channel, echoMessages check) | ~18 | -| `unit/live_object_subscribe.md` | RTLO4b, RTLO4c (subscribe/unsubscribe on internal LiveObject) | ~8 | -| `unit/path_object.md` | RTPO1-14 (navigation, value, instance, entries, compact, compactJson) | ~33 | -| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2 (set, remove, increment, decrement, error on unresolvable path) | ~12 | -| `unit/path_object_subscribe.md` | RTPO19-21, RTO24 (path subscriptions, depth filtering, path-following semantics, subscribeIterator) | ~20 | -| `unit/instance.md` | RTINS1-18 (id, value, get, entries, size, compact, set, remove, increment, subscribe) | ~26 | -| `unit/batch.md` | RTPO22, RTINS19, RTBC1-16 (batch entry, BatchContext methods, RootBatchContext flush/close) | ~20 | +| `unit/live_map_api.md` | RTLM5, RTLM10-13, RTLM20-21, RTLM24, RTLCV4, RTLMV4 (reads + mutations, value type evaluation) | ~20 | +| `unit/live_object_subscribe.md` | RTLO4b, RTLO4b4c3, RTLO4b4d-e, RTLO4b7 (subscribe, dispatch chain, tombstone cleanup, Subscription) | ~11 | +| `unit/path_object.md` | RTPO1-14, RTO25 (navigation, value, instance, entries, compact, compactJson, access preconditions) | ~27 | +| `unit/path_object_mutations.md` | RTPO15-18, RTPO3c2, RTO26 (set, remove, increment, decrement, write preconditions) | ~14 | +| `unit/path_object_subscribe.md` | RTPO19, RTO24 (path subscriptions, depth filtering, dispatch, PAOM delivery) | ~22 | +| `unit/instance.md` | RTINS1-16 (id, value, get, entries, size, compact, set, remove, increment, subscribe, RTO25/26) | ~21 | ### Integration Tests (sandbox) | File | Spec Points | ~Tests | |------|-------------|--------| | `integration/objects_lifecycle_test.md` | RTO23, RTPO15, RTPO17 (create objects, mutate via PathObject, read back, REST provisioning) | ~6 | | `integration/objects_sync_test.md` | RTO4, RTO5, RTO17 (attach, sync sequence, re-attach) | ~4 | -| `integration/objects_batch_test.md` | RTPO22, RTBC12-15 (batch publish, atomic delivery) | ~3 | +| ~~`integration/objects_batch_test.md`~~ | ~~Batch API not in current spec revision~~ | — | | `integration/objects_gc_test.md` | RTO10, RTLM19 (behavioral GC verification with ADVANCE_TIME) | ~2 | ### Proxy Integration Tests @@ -62,7 +63,7 @@ All new test files go in `specification/uts/objects/`. |------|-------------|--------| | `integration/proxy/objects_faults.md` | RTO5a2, RTO7, RTO8, RTO17, RTO20e (sync interruption, mutation buffering during re-sync, server-initiated detach, publish failure on FAILED channel, publish during delayed sync) | ~5 | -**Totals: ~21 files, ~330 tests** +**Totals: ~20 files, ~310 tests** --- @@ -198,17 +199,17 @@ Pure function tests: ### `unit/value_types.md` -- LiveCounterValueType / LiveMapValueType -Tests the static `create()` factories and consumption procedure. +Tests the static `create()` factories and evaluation procedure. **LiveCounterValueType (RTLCV1-4):** 1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 2. `LiveCounter.create()` -> count defaults to 0 -3. Consumption: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` -4. Non-number count throws 40003 during consumption +3. Evaluation: validates count, builds CounterCreate, generates objectId, returns ObjectMessage with `counterCreateWithObjectId.{nonce, initialValue}` +4. Non-number count throws 40003 during evaluation **LiveMapValueType (RTLMV1-4):** 1. `LiveMap.create({entries})` -> immutable LiveMapValueType -2. Consumption: validates keys/values, builds entries, generates objectId, returns ObjectMessage with `mapCreateWithObjectId.{nonce, initialValue}` +2. Evaluation: validates keys/values, builds entries, generates objectId, returns ObjectMessage with `mapCreateWithObjectId.{nonce, initialValue}` 3. Nested value types: LiveMapValueType containing LiveCounterValueType -> depth-first ObjectMessage array (inner creates before outer) 4. Retains local MapCreate/CounterCreate alongside wire format (RTLMV4j5/RTLCV4g5) @@ -253,14 +254,16 @@ Uses `setup_synced_channel()` from helper. ### `unit/path_object_subscribe.md` -- Path-Based Subscriptions -- **RTPO19:** subscribe returns Subscription, listener receives PathObjectSubscriptionEvent -- **RTPO19b1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) -- **RTPO19b1d:** non-positive depth throws 40003 -- **RTPO19e:** follows path not identity -- object replacement at path -> subscription tracks new object -- **RTPO19f:** child events bubble up to parent subscription -- **RTO24b3:** depth formula: `eventPath.length - subscriptionPath.length + 1 <= depth` -- **RTO24b5:** listener exception caught, doesn't affect other listeners -- **RTPO20:** unsubscribe deregisters +- **RTPO19:** subscribe returns Subscription (RTPO19d), listener receives PathObjectSubscriptionEvent (RTPO19e) +- **RTPO19b:** checks RTO25 access API preconditions +- **RTPO19c1:** depth filtering -- depth=1 (self only), depth=2 (self+children), undefined (all) +- **RTPO19c1a:** non-positive depth throws 40003 +- **RTPO19e2:** event.message carries PublicAPI::ObjectMessage when operation present +- **RTPO19f:** follows path not identity -- object replacement at path -> subscription tracks new object +- **RTO24b2a:** candidate path construction includes map update keys +- **RTO24c1:** coverage rule: prefix match + depth constraint +- **RTO24b2c:** listener exception caught, doesn't affect other listeners +- **RTO24b1:** multi-path dispatch via getFullPaths ### `unit/instance.md` -- Identity-Bound Reference @@ -287,26 +290,29 @@ Uses `setup_synced_channel()` from helper. - **RTLM5:** get(key) returns resolved value - **RTLM10/RTLM11:** entries/keys/values iterate non-tombstoned entries - **RTLM12/RTLM13:** set/remove construct correct v6 wire ObjectMessages -- **RTLM20:** set with LiveCounterValueType/LiveMapValueType consumes value type +- **RTLM20:** set with LiveCounterValueType/LiveMapValueType evaluates value type - **RTLM20d/RTLM21d:** echoMessages=false uses publish instead of publishAndApply - **RTLM24:** clear constructs MAP_CLEAR ObjectMessage ### `unit/live_object_subscribe.md` -- Internal Subscription -- **RTLO4b:** subscribe(listener) registers on internal LiveObject -- **RTLO4c:** unsubscribe removes listener -- Events fire on applyOperation with update details +- **RTLO4b:** subscribe(listener) registers on internal LiveObject, returns Subscription (RTLO4b7) +- **RTLO4b4c3:** dispatch chain: direct listeners → path dispatch → tombstone cleanup +- **RTLO4b4d/e:** LiveObjectUpdate carries objectMessage and tombstone fields +- Subscription#unsubscribe deregisters (idempotent) +- Tombstone update deregisters all direct listeners (RTLO4b4c3c) -### `unit/batch.md` -- Batch API +### `unit/parent_references.md` -- parentReferences Tracking -- **RTPO22/RTINS19:** batch entry points -- resolve to LiveObject, create RootBatchContext, execute fn, flush -- **RTPO22c/RTINS19c:** unresolvable path / non-LiveObject throws 92007 -- **RTBC3-11:** read methods delegate to Instance (id, value, get, entries, keys, values, size, compact, compactJson) -- **RTBC4d:** get() wraps result via RootBatchContext#wrapInstance (memoized by objectId -- RTBC16c) -- **RTBC12-15:** write methods (set, remove, increment, decrement) queue message constructors synchronously -- **RTBC16d:** flush executes constructors, publishes all as single array via RTO15 (NOT publishAndApply) -- **RTBC16e:** closed batch throws 40000 on any method call -- **RTBC16f:** RootBatchContext closed after flush regardless of success/failure +- **RTLO3f:** parentReferences initialized to empty Dict> +- **RTLO4g/RTLO4h:** addParentReference/removeParentReference methods +- **RTLO4f:** getFullPaths — DFS traversal of inverse parentReferences graph, simple paths only +- **RTO5c10:** post-sync parentReferences rebuild from LiveMap entries + +### `unit/public_object_message.md` -- User-Facing Event Types + +- **PAOM1-3:** PublicAPI::ObjectMessage construction from internal ObjectMessage +- **PAOOP1-3:** PublicAPI::ObjectOperation construction, mapCreate/counterCreate resolution from *WithObjectId variants --- @@ -341,23 +347,23 @@ onMessageFromClient: (msg) => { ## Dependency Ordering (write order) 1. `helpers/standard_test_pool.md` -2. `unit/live_counter.md` -- no dependencies -3. `unit/live_map.md` -- no dependencies -4. `unit/object_id.md` -- no dependencies -5. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts -6. `unit/value_types.md` -- uses objectId generation -7. `unit/realtime_object.md` -- uses helper, tests orchestration -8. `unit/live_counter_api.md` -- uses helper -9. `unit/live_map_api.md` -- uses helper -10. `unit/live_object_subscribe.md` -- uses helper -11. `unit/path_object.md` -- uses helper -12. `unit/instance.md` -- uses helper -13. `unit/path_object_mutations.md` -- uses helper -14. `unit/path_object_subscribe.md` -- uses helper -15. `unit/batch.md` -- uses helper, depends on PathObject/Instance concepts -16. `integration/objects_lifecycle_test.md` -17. `integration/objects_sync_test.md` -18. `integration/objects_batch_test.md` +2. `unit/parent_references.md` -- foundational for graph tracking +3. `unit/public_object_message.md` -- standalone type construction +4. `unit/live_counter.md` -- no dependencies +5. `unit/live_map.md` -- no dependencies +6. `unit/object_id.md` -- no dependencies +7. `unit/objects_pool.md` -- uses LiveCounter/LiveMap concepts +8. `unit/value_types.md` -- uses objectId generation +9. `unit/realtime_object.md` -- uses helper, tests orchestration +10. `unit/live_counter_api.md` -- uses helper +11. `unit/live_map_api.md` -- uses helper +12. `unit/live_object_subscribe.md` -- uses helper +13. `unit/path_object.md` -- uses helper +14. `unit/instance.md` -- uses helper +15. `unit/path_object_mutations.md` -- uses helper +16. `unit/path_object_subscribe.md` -- uses helper +17. `integration/objects_lifecycle_test.md` +18. `integration/objects_sync_test.md` 19. `integration/objects_gc_test.md` 20. `integration/proxy/objects_faults.md` @@ -370,8 +376,8 @@ onMessageFromClient: (msg) => { | Wire format v6 everywhere | Spec branch uses v6 field names; old v5 names are "replaced by" stubs | | `appliedOnAckSerials` on RealtimeObject (RTO7b), not on pool | Matches spec's placement; cleared at sync completion (RTO5c9) | | No REST test files | objects-features.md has no REST API spec points; REST used only for integration fixture provisioning | -| `echoMessages` check retained on mutations | Spec retains RTLC12d, RTLM20d, RTLM21d | -| Batch uses RTO15 (publish), NOT RTO20 (publishAndApply) | RTBC16d says "publishes ... using `RealtimeObject#publish`" -- batch does NOT apply locally on ACK | +| `echoMessages` check moved to RTO26 | RTO26c checks echoMessages=false; callers (PathObject/Instance) enforce via RTO26 | +| Batch API deferred | Not included in current spec revision (a397e34); may be added in a future spec update | | LiveObject/LiveMap/LiveCounter marked internal but still unit-tested | Direct testing of CRDT logic is essential; public API tests can't cover all edge cases | | Test IDs use `objects/unit/` prefix | Matches directory structure, not nested under `realtime/` | | Behavioral GC testing via ADVANCE_TIME | Verify GC through observable consequences (value becomes null, object recreatable) rather than internal pool state inspection | diff --git a/uts/objects/helpers/standard_test_pool.md b/uts/objects/helpers/standard_test_pool.md index e01062903..093b1e996 100644 --- a/uts/objects/helpers/standard_test_pool.md +++ b/uts/objects/helpers/standard_test_pool.md @@ -32,6 +32,20 @@ map:prefs@1000 (LiveMap, semantics: LWW) All map entries have timeserial `"t:0"` and `tombstone: false` unless otherwise noted. All objects have `siteTimeserials: { "aaa": "t:0" }` and `createOperationIsMerged: true` unless otherwise noted. +### Expected parentReferences after sync + +After `setup_synced_channel` completes (including the RTO5c10 rebuild), each object's `parentReferences` should be: + +| Object | parentReferences | +|--------|-----------------| +| `root` | `{}` (empty -- root is not referenced by any parent) | +| `counter:score@1000` | `{ "root": {"score"} }` | +| `map:profile@1000` | `{ "root": {"profile"} }` | +| `counter:nested@1000` | `{ "map:profile@1000": {"nested_counter"} }` | +| `map:prefs@1000` | `{ "map:profile@1000": {"prefs"} }` | + +Only entries whose value is a `LiveObject` (i.e. `data.objectId` is present) contribute to parentReferences. Primitive-valued entries ("name", "age", "active", "data", "avatar", "email", "theme") do not. + --- ## STANDARD_POOL_OBJECTS @@ -216,12 +230,43 @@ build_object_state(objectId, siteTimeserials, opts): RETURN ObjectMessage(object: state) ``` +### ObjectMessage Builder (State wrapper) + +Wraps an existing `ObjectState` in an `ObjectMessage` with the `object` field populated. Used when `replaceData` (RTLC6, RTLM6) needs an `ObjectMessage` rather than a bare `ObjectState`. + +```pseudo +build_object_message_with_state(objectState): + RETURN ObjectMessage(object: objectState) +``` + +### PublicAPI::ObjectMessage Builder + +Constructs a `PublicAPI::ObjectMessage` from an internal `ObjectMessage` and a channel name, per PAOM3. Used by subscription tests that verify the user-facing message delivered to listeners. + +```pseudo +build_public_object_message(objectMessage, channelName): + pub = PublicAPI::ObjectMessage() + pub.channel = channelName + pub.id = objectMessage.id + pub.clientId = objectMessage.clientId + pub.connectionId = objectMessage.connectionId + pub.timestamp = objectMessage.timestamp + pub.serial = objectMessage.serial + pub.serialTimestamp = objectMessage.serialTimestamp + pub.siteCode = objectMessage.siteCode + pub.extras = objectMessage.extras + pub.operation = PublicAPI::ObjectOperation from objectMessage.operation per PAOOP3 + RETURN pub +``` + --- ## Standard Synced-Channel Setup Used by all mock WebSocket test files. Creates a connected client with a synced channel containing the standard test pool. +After the OBJECT_SYNC sequence completes, the SDK rebuilds parentReferences per RTO5c10: reset all LiveObject parentReferences to empty (RTLO3f2), then iterate all LiveMap entries calling addParentReference (RTLO4g) for each entry whose value is a LiveObject. See "Expected parentReferences after sync" above for the resulting state. + ```pseudo setup_synced_channel(channel_name): mock_ws = MockWebSocket( diff --git a/uts/objects/unit/batch.md b/uts/objects/unit/batch.md deleted file mode 100644 index b53098c35..000000000 --- a/uts/objects/unit/batch.md +++ /dev/null @@ -1,782 +0,0 @@ -# Batch API Tests - -Spec points: `RTPO22`, `RTINS19`, `RTBC1`–`RTBC16` - -## Test Type -Unit test with mocked WebSocket client - -## Mock WebSocket Infrastructure - -See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. - -## Shared Helpers - -See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder functions. - ---- - -## RTPO22 - PathObject#batch resolves path and executes fn - -**Test ID**: `objects/unit/RTPO22/batch-resolves-and-executes-0` - -| Spec | Requirement | -|------|-------------| -| RTPO22c | Resolves path to LiveObject | -| RTPO22d | Creates RootBatchContext wrapping Instance | -| RTPO22e | Executes fn with BatchContext | -| RTPO22f | Flushes after fn returns | - -### Setup -```pseudo -captured_messages = [] -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - ELSE IF msg.action == OBJECT: - captured_messages.append(msg) - serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) - mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.set("name", "Bob") - ctx.set("age", 31) -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -ASSERT captured_messages[0].state.length == 2 -ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[0].operation.mapSet.key == "name" -ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[1].operation.mapSet.key == "age" -``` - ---- - -## RTPO22c - PathObject#batch on unresolvable path throws 92007 - -**Test ID**: `objects/unit/RTPO22c/batch-unresolvable-throws-0` - -**Spec requirement:** If path does not resolve to LiveObject, throw 92007. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -AWAIT root.get("nonexistent").get("deep").batch((ctx) => {}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 92007 -``` - ---- - -## RTINS19 - Instance#batch resolves and executes fn - -**Test ID**: `objects/unit/RTINS19/batch-instance-executes-0` - -| Spec | Requirement | -|------|-------------| -| RTINS19d | Creates RootBatchContext wrapping Instance | -| RTINS19e | Executes fn with BatchContext | -| RTINS19f | Flushes after fn returns | - -### Setup -```pseudo -captured_messages = [] -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - ELSE IF msg.action == OBJECT: - captured_messages.append(msg) - serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) - mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -instance = root.instance() -AWAIT instance.batch((ctx) => { - ctx.set("name", "Charlie") - ctx.remove("age") -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -ASSERT captured_messages[0].state.length == 2 -ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[1].operation.action == "MAP_REMOVE" -``` - ---- - -## RTINS19c - Instance#batch on non-LiveObject throws 92007 - -**Test ID**: `objects/unit/RTINS19c/batch-non-live-object-throws-0` - -**Spec requirement:** If wrapped value is not a LiveObject, throw 92007. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -name_inst = root.instance().get("name") -AWAIT name_inst.batch((ctx) => {}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 92007 -``` - ---- - -## RTBC3 - BatchContext#id returns objectId - -**Test ID**: `objects/unit/RTBC3/id-returns-objectid-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -received_id = null -AWAIT root.batch((ctx) => { - received_id = ctx.id() -}) -``` - -### Assertions -```pseudo -ASSERT received_id == "root" -``` - ---- - -## RTBC5 - BatchContext#value delegates to Instance#value - -**Test ID**: `objects/unit/RTBC5/value-delegates-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -received_value = null -AWAIT root.get("score").batch((ctx) => { - received_value = ctx.value() -}) -``` - -### Assertions -```pseudo -ASSERT received_value == 100 -``` - ---- - -## RTBC4 - BatchContext#get wraps result via wrapInstance - -**Test ID**: `objects/unit/RTBC4/get-wraps-instance-0` - -| Spec | Requirement | -|------|-------------| -| RTBC4c | Delegates to Instance#get | -| RTBC4d | Wraps result via RootBatchContext#wrapInstance | - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -child_id = null -AWAIT root.batch((ctx) => { - child = ctx.get("score") - child_id = child.id() -}) -``` - -### Assertions -```pseudo -ASSERT child_id == "counter:score@1000" -``` - ---- - -## RTBC4 - BatchContext#get returns null for nonexistent key - -**Test ID**: `objects/unit/RTBC4/get-null-nonexistent-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -result = "not_null" -AWAIT root.batch((ctx) => { - result = ctx.get("nonexistent") -}) -``` - -### Assertions -```pseudo -ASSERT result == null -``` - ---- - -## RTBC6 - BatchContext#entries yields [key, BatchContext] pairs - -**Test ID**: `objects/unit/RTBC6/entries-yields-pairs-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -keys = [] -AWAIT root.batch((ctx) => { - FOR [key, child] IN ctx.entries(): - keys.append(key) -}) -``` - -### Assertions -```pseudo -ASSERT keys.length == 6 -ASSERT "name" IN keys -ASSERT "score" IN keys -``` - ---- - -## RTBC9 - BatchContext#size delegates to Instance#size - -**Test ID**: `objects/unit/RTBC9/size-delegates-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -received_size = null -AWAIT root.batch((ctx) => { - received_size = ctx.size() -}) -``` - -### Assertions -```pseudo -ASSERT received_size == 6 -``` - ---- - -## RTBC10 - BatchContext#compact delegates to Instance#compact - -**Test ID**: `objects/unit/RTBC10/compact-delegates-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -result = null -AWAIT root.batch((ctx) => { - result = ctx.compact() -}) -``` - -### Assertions -```pseudo -ASSERT result["name"] == "Alice" -ASSERT result["score"] == 100 -``` - ---- - -## RTBC12 - BatchContext#set queues MAP_SET message - -**Test ID**: `objects/unit/RTBC12/set-queues-map-set-0` - -| Spec | Requirement | -|------|-------------| -| RTBC12d | Queues message constructor for MAP_SET | - -### Setup -```pseudo -captured_messages = [] -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - ELSE IF msg.action == OBJECT: - captured_messages.append(msg) - serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) - mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.set("name", "Bob") -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "MAP_SET" -ASSERT obj_msg.operation.objectId == "root" -ASSERT obj_msg.operation.mapSet.key == "name" -ASSERT obj_msg.operation.mapSet.value.string == "Bob" -``` - ---- - -## RTBC12c - BatchContext#set on non-LiveMap throws 92007 - -**Test ID**: `objects/unit/RTBC12c/set-non-map-throws-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -AWAIT root.get("score").batch((ctx) => { - ctx.set("key", "value") -}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 92007 -``` - ---- - -## RTBC13 - BatchContext#remove queues MAP_REMOVE message - -**Test ID**: `objects/unit/RTBC13/remove-queues-map-remove-0` - -### Setup -```pseudo -captured_messages = [] -// (same mock setup as RTPO22, capturing OBJECT messages) -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.remove("name") -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "MAP_REMOVE" -ASSERT obj_msg.operation.objectId == "root" -ASSERT obj_msg.operation.mapRemove.key == "name" -``` - ---- - -## RTBC14 - BatchContext#increment queues COUNTER_INC message - -**Test ID**: `objects/unit/RTBC14/increment-queues-counter-inc-0` - -### Setup -```pseudo -captured_messages = [] -// (same mock setup as RTPO22, capturing OBJECT messages) -``` - -### Test Steps -```pseudo -AWAIT root.get("score").batch((ctx) => { - ctx.increment(25) -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "COUNTER_INC" -ASSERT obj_msg.operation.objectId == "counter:score@1000" -ASSERT obj_msg.operation.counterInc.number == 25 -``` - ---- - -## RTBC14c - BatchContext#increment on non-LiveCounter throws 92007 - -**Test ID**: `objects/unit/RTBC14c/increment-non-counter-throws-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.increment(5) -}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 92007 -``` - ---- - -## RTBC15 - BatchContext#decrement delegates to increment with negated amount - -**Test ID**: `objects/unit/RTBC15/decrement-negates-0` - -### Setup -```pseudo -captured_messages = [] -// (same mock setup as RTPO22, capturing OBJECT messages) -``` - -### Test Steps -```pseudo -AWAIT root.get("score").batch((ctx) => { - ctx.decrement(10) -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 1 -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "COUNTER_INC" -ASSERT obj_msg.operation.counterInc.number == -10 -``` - ---- - -## RTBC16c - wrapInstance memoizes by objectId - -**Test ID**: `objects/unit/RTBC16c/wrap-instance-memoized-0` - -**Spec requirement:** If a wrapper for that objectId already exists, the existing wrapper is returned. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -``` - -### Test Steps -```pseudo -same_ref = false -AWAIT root.batch((ctx) => { - child1 = ctx.get("score") - child2 = ctx.get("score") - same_ref = (child1 IS child2) -}) -``` - -### Assertions -```pseudo -ASSERT same_ref == true -``` - ---- - -## RTBC16d - flush publishes via RTO15 (publish, not publishAndApply) - -**Test ID**: `objects/unit/RTBC16d/flush-uses-publish-0` - -**Spec requirement:** Flushes queued messages as a single array via RealtimeObject#publish. - -### Setup -```pseudo -captured_messages = [] -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - ELSE IF msg.action == OBJECT: - captured_messages.append(msg) - serials = msg.state.map((_, i) => "ack-" + msg.msgSerial + ":" + i) - mock_ws.send_to_client(build_ack_message(msg.msgSerial, serials)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.set("name", "Bob") - ctx.set("age", 31) - child = ctx.get("score") - child.increment(50) -}) -``` - -### Assertions -```pseudo -// All operations published as a single OBJECT message -ASSERT captured_messages.length == 1 -ASSERT captured_messages[0].state.length == 3 -ASSERT captured_messages[0].state[0].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[1].operation.action == "MAP_SET" -ASSERT captured_messages[0].state[2].operation.action == "COUNTER_INC" -``` - ---- - -## RTBC16d - flush with no queued messages does not publish - -**Test ID**: `objects/unit/RTBC16d/flush-empty-no-publish-0` - -**Spec requirement:** If there are no queued messages, no publish is performed. - -### Setup -```pseudo -captured_messages = [] -// (same mock setup as RTPO22, capturing OBJECT messages) -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - // Read-only: no writes queued - ctx.value() - ctx.size() -}) -``` - -### Assertions -```pseudo -ASSERT captured_messages.length == 0 -``` - ---- - -## RTBC16e - closed batch throws 40000 on any method call - -**Test ID**: `objects/unit/RTBC16e/closed-batch-throws-0` - -**Spec requirement:** After the batch is closed, any method call must throw 40000. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -saved_ctx = null -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - saved_ctx = ctx -}) - -saved_ctx.set("name", "Bob") FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40000 -``` - ---- - -## RTBC16e - closed batch read methods also throw 40000 - -**Test ID**: `objects/unit/RTBC16e/closed-batch-read-throws-0` - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -saved_ctx = null -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - saved_ctx = ctx -}) - -saved_ctx.id() FAILS WITH error_id -saved_ctx.value() FAILS WITH error_value -saved_ctx.size() FAILS WITH error_size -``` - -### Assertions -```pseudo -ASSERT error_id.code == 40000 -ASSERT error_value.code == 40000 -ASSERT error_size.code == 40000 -``` - ---- - -## RTPO22g - RootBatchContext closed after flush regardless of success - -**Test ID**: `objects/unit/RTPO22g/closed-after-flush-0` - -**Spec requirement:** The RootBatchContext is closed after flush completes, regardless of success or failure. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -saved_ctx = null -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - saved_ctx = ctx - ctx.set("name", "Bob") -}) - -saved_ctx.set("age", 99) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40000 -``` - ---- - -## RTPO22b - PathObject#batch requires OBJECT_PUBLISH mode - -**Test ID**: `objects/unit/RTPO22b/batch-requires-publish-mode-0` - -**Spec requirement:** Requires OBJECT_PUBLISH channel mode per RTO2. - -### Setup -```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", - flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.batch((ctx) => { - ctx.set("name", "Bob") -}) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40024 -``` diff --git a/uts/objects/unit/instance.md b/uts/objects/unit/instance.md index 221d635e7..bf8a67b47 100644 --- a/uts/objects/unit/instance.md +++ b/uts/objects/unit/instance.md @@ -1,6 +1,6 @@ # Instance Tests -Spec points: `RTINS1`–`RTINS19` +Spec points: `RTINS1`–`RTINS16` ## Test Type Unit test with mocked WebSocket client @@ -46,8 +46,10 @@ ASSERT map_inst.id() == "map:profile@1000" | Spec | Requirement | |------|-------------| -| RTINS4a | LiveCounter -> numeric value | -| RTINS4c | LiveMap -> null | +| RTINS4a | Checks access API preconditions per RTO25 | +| RTINS4b | LiveCounter -> delegates to LiveCounter#value | +| RTINS4c | Primitive -> returns value directly | +| RTINS4d | LiveMap -> null | ### Setup ```pseudo @@ -71,8 +73,9 @@ ASSERT map_inst.value() == null | Spec | Requirement | |------|-------------| -| RTINS5b | LiveMap -> look up key, wrap result in Instance | -| RTINS5c | Non-LiveMap -> null | +| RTINS5b | Checks access API preconditions per RTO25 | +| RTINS5c | LiveMap -> look up key, wrap result in Instance | +| RTINS5d | Non-LiveMap -> null | ### Setup ```pseudo @@ -95,14 +98,15 @@ ASSERT null_inst == null --- -## RTINS6 - entries() yields [key, Instance] pairs +## RTINS6 - entries() returns array of [key, Instance] pairs **Test ID**: `objects/unit/RTINS6/entries-yields-instances-0` | Spec | Requirement | |------|-------------| -| RTINS6a | LiveMap -> [key, Instance] pairs | -| RTINS6b | Non-LiveMap -> empty iterator | +| RTINS6a | Checks access API preconditions per RTO25 | +| RTINS6b | LiveMap -> array of [key, Instance] pairs | +| RTINS6c | Non-LiveMap -> empty array | ### Setup ```pseudo @@ -132,8 +136,9 @@ ASSERT entries["name"].value() == "Alice" | Spec | Requirement | |------|-------------| -| RTINS9a | LiveMap -> non-tombstoned entry count | -| RTINS9b | Non-LiveMap -> null | +| RTINS9a | Checks access API preconditions per RTO25 | +| RTINS9b | LiveMap -> non-tombstoned entry count | +| RTINS9c | Non-LiveMap -> null | ### Setup ```pseudo @@ -155,7 +160,10 @@ ASSERT counter_inst.size() == null **Test ID**: `objects/unit/RTINS10/compact-0` -**Spec requirement:** Behaves identically to PathObject#compact on the wrapped value. +| Spec | Requirement | +|------|-------------| +| RTINS10a | Checks access API preconditions per RTO25 | +| RTINS10b | Behaves identically to PathObject#compact on the wrapped value | ### Setup ```pseudo @@ -183,8 +191,9 @@ ASSERT result["profile"]["email"] == "alice@example.com" | Spec | Requirement | |------|-------------| -| RTINS12b | LiveMap -> delegate to LiveMap#set | -| RTINS12c | Non-LiveMap -> throw 92007 | +| RTINS12b | Checks write API preconditions per RTO26 | +| RTINS12c | LiveMap -> delegate to LiveMap#set | +| RTINS12d | Non-LiveMap -> throw 92007 | ### Setup ```pseudo @@ -204,9 +213,11 @@ ASSERT root.get("name").value() == "Bob" --- -## RTINS12c - set() on non-LiveMap throws 92007 +## RTINS12d - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTINS12d/set-non-map-throws-0` -**Test ID**: `objects/unit/RTINS12c/set-non-map-throws-0` +**Spec requirement:** If the wrapped value is not a LiveMap, throw ErrorInfo with code 92007. ### Setup ```pseudo @@ -230,6 +241,12 @@ ASSERT error.code == 92007 **Test ID**: `objects/unit/RTINS13/remove-delegates-0` +| Spec | Requirement | +|------|-------------| +| RTINS13b | Checks write API preconditions per RTO26 | +| RTINS13c | LiveMap -> delegate to LiveMap#remove | +| RTINS13d | Non-LiveMap -> throw 92007 | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -254,8 +271,9 @@ ASSERT root.get("name").value() == null | Spec | Requirement | |------|-------------| -| RTINS14b | LiveCounter -> delegate to increment | -| RTINS14c | Non-LiveCounter -> throw 92007 | +| RTINS14b | Checks write API preconditions per RTO26 | +| RTINS14c | LiveCounter -> delegate to increment | +| RTINS14d | Non-LiveCounter -> throw 92007 | ### Setup ```pseudo @@ -275,9 +293,11 @@ ASSERT root.get("score").value() == 125 --- -## RTINS14c - increment() on non-LiveCounter throws 92007 +## RTINS14d - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTINS14d/increment-non-counter-throws-0` -**Test ID**: `objects/unit/RTINS14c/increment-non-counter-throws-0` +**Spec requirement:** If the wrapped value is not a LiveCounter, throw ErrorInfo with code 92007. ### Setup ```pseudo @@ -301,6 +321,12 @@ ASSERT error.code == 92007 **Test ID**: `objects/unit/RTINS15/decrement-delegates-0` +| Spec | Requirement | +|------|-------------| +| RTINS15b | Checks write API preconditions per RTO26 | +| RTINS15c | LiveCounter -> delegate to decrement | +| RTINS15d | Non-LiveCounter -> throw 92007 | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -319,16 +345,65 @@ ASSERT root.get("score").value() == 90 --- +## RTINS14a - increment() defaults to 1 + +**Test ID**: `objects/unit/RTINS14a/increment-default-0` + +**Spec requirement:** amount defaults to 1 (RTINS14a1). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.increment() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 101 +``` + +--- + +## RTINS15a - decrement() defaults to 1 + +**Test ID**: `objects/unit/RTINS15a/decrement-default-0` + +**Spec requirement:** amount defaults to 1 (RTINS15a1). + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement() +``` + +### Assertions +```pseudo +ASSERT root.get("score").value() == 99 +``` + +--- + ## RTINS16 - subscribe() receives InstanceSubscriptionEvent **Test ID**: `objects/unit/RTINS16/subscribe-receives-events-0` | Spec | Requirement | |------|-------------| -| RTINS16c | Subscribes via LiveObject#subscribe | -| RTINS16d1 | Event.object is the Instance | -| RTINS16e | Returns Subscription | -| RTINS16f | Identity-based subscription | +| RTINS16b | Checks access API preconditions per RTO25 | +| RTINS16d | Subscribes via LiveObject#subscribe (RTLO4b) | +| RTINS16e1 | Event.object is an Instance wrapping the LiveObject | +| RTINS16f | Returns Subscription | +| RTINS16g | Identity-based subscription | ### Setup ```pseudo @@ -356,11 +431,11 @@ ASSERT events[0].object.id() == "counter:score@1000" --- -## RTINS16b - subscribe() on primitive throws 92007 +## RTINS16c - subscribe() on primitive throws 92007 -**Test ID**: `objects/unit/RTINS16b/subscribe-primitive-throws-0` +**Test ID**: `objects/unit/RTINS16c/subscribe-primitive-throws-0` -**Spec requirement:** If wrapped value is not LiveObject, throw 92007. +**Spec requirement:** If wrapped value is not a LiveObject (i.e. it is a primitive), throw ErrorInfo with code 92007. ### Setup ```pseudo @@ -380,43 +455,51 @@ ASSERT error.code == 92007 --- -## RTINS16f - Instance subscription follows identity not path +## RTINS16e2 - InstanceSubscriptionEvent contains PublicAPI::ObjectMessage + +**Test ID**: `objects/unit/RTINS16e2/subscription-event-message-0` -**Test ID**: `objects/unit/RTINS16f/subscription-follows-identity-0` +| Spec | Requirement | +|------|-------------| +| RTINS16e1 | Event.object is an Instance wrapping the LiveObject | +| RTINS16e2 | Event.message is a PublicAPI::ObjectMessage derived from the triggering ObjectMessage | -**Spec requirement:** Instance follows the specific LiveObject, regardless of tree position. +Tests that the InstanceSubscriptionEvent includes both the `object` (Instance) and `message` (PublicAPI::ObjectMessage) fields when a data update arrives. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -counter_inst = root.get("score").instance() +root_inst = root.instance() events = [] -counter_inst.subscribe((event) => events.append(event)) +root_inst.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") -])) - -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 10, "100", "remote") + build_map_set("root", "name", { string: "Bob" }, "99", "remote") ])) poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length >= 1 -ASSERT counter_inst.id() == "counter:score@1000" +ASSERT events[0].object IS Instance +ASSERT events[0].object.id() == "root" +ASSERT events[0].message IS NOT null +ASSERT events[0].message.channel == "test" +ASSERT events[0].message.operation.action == "MAP_SET" +ASSERT events[0].message.operation.objectId == "root" +ASSERT events[0].message.operation.mapSet.key == "name" ``` --- -## RTINS17 - unsubscribe() deregisters listener +## RTINS16f - subscribe() returns Subscription for deregistration + +**Test ID**: `objects/unit/RTINS16f/subscribe-returns-subscription-0` -**Test ID**: `objects/unit/RTINS17/unsubscribe-0` +**Spec requirement:** Returns a Subscription object (RTINS16f). Deregistration is via Subscription#unsubscribe. ### Setup ```pseudo @@ -441,84 +524,59 @@ ASSERT events.length == 0 --- -## RTINS14a - increment() defaults to 1 +## RTINS16g - Instance subscription follows identity not path -**Test ID**: `objects/unit/RTINS14a/increment-default-0` +**Test ID**: `objects/unit/RTINS16g/subscription-follows-identity-0` -**Spec requirement:** amount defaults to 1. +**Spec requirement:** The subscription is identity-based: it follows the specific LiveObject instance, regardless of where it sits in the graph. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") counter_inst = root.get("score").instance() +events = [] +counter_inst.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -AWAIT counter_inst.increment() -``` - -### Assertions -```pseudo -ASSERT root.get("score").value() == 101 -``` - ---- - -## RTINS15a - decrement() defaults to 1 - -**Test ID**: `objects/unit/RTINS15a/decrement-default-0` - -**Spec requirement:** amount defaults to 1. - -### Setup -```pseudo -{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -counter_inst = root.get("score").instance() -``` +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) -### Test Steps -```pseudo -AWAIT counter_inst.decrement() +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 10, "100", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT root.get("score").value() == 99 +ASSERT events.length >= 1 +ASSERT counter_inst.id() == "counter:score@1000" ``` --- -## RTINS16 - Subscription event contains message metadata +## RTINS16h - subscribe() has no side effects -**Test ID**: `objects/unit/RTINS16/subscription-event-metadata-0` +**Test ID**: `objects/unit/RTINS16h/subscribe-no-side-effects-0` -| Spec | Requirement | -|------|-------------| -| RTINS16d1 | Event.object is the Instance | -| RTINS16d2 | Event.message is the ObjectMessage that triggered the update | +**Spec requirement:** The subscribe operation must not have any side effects on RealtimeObject, the underlying channel, or their status. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -root_inst = root.instance() -events = [] -root_inst.subscribe((event) => events.append(event)) +counter_inst = root.get("score").instance() +channel_state_before = channel.state ``` ### Test Steps ```pseudo -mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "name", { string: "Bob" }, "99", "remote") -])) -poll_until(events.length >= 1, timeout: 5s) +sub = counter_inst.subscribe((event) => {}) ``` ### Assertions ```pseudo -ASSERT events[0].object IS Instance -ASSERT events[0].object.id() == "root" -ASSERT events[0].message IS NOT null -ASSERT events[0].message.operation.action == "MAP_SET" -ASSERT events[0].message.operation.mapSet.key == "name" +ASSERT channel.state == channel_state_before ``` diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/live_counter.md index 300f1779b..d5f2c3401 100644 --- a/uts/objects/unit/live_counter.md +++ b/uts/objects/unit/live_counter.md @@ -1,6 +1,6 @@ # LiveCounter Tests -Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` +Spec points: `RTLC1`, `RTLC3`, `RTLC4`, `RTLC6`, `RTLC7`, `RTLC8`, `RTLC9`, `RTLC14`, `RTLC16`, `RTLO3`, `RTLO4a`, `RTLO4b4d`, `RTLO4b4e`, `RTLO4e`, `RTLO5`, `RTLO6` ## Test Type Unit test — pure data structure, no mocks required. @@ -47,7 +47,7 @@ ASSERT counter.siteTimeserials == {} | Spec | Requirement | |------|-------------| | RTLC9f | Add `CounterInc.number` to data if it exists | -| RTLC9g | Return LiveCounterUpdate with amount set to the number | +| RTLC9g | Return LiveCounterUpdate with amount set to the number and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -65,6 +65,7 @@ update = counter.applyOperation(msg, source: CHANNEL) ASSERT counter.data == 5 ASSERT update.noop == false ASSERT update.update.amount == 5 +ASSERT update.objectMessage == msg ``` --- @@ -92,6 +93,7 @@ update = counter.applyOperation(msg, source: CHANNEL) ```pseudo ASSERT counter.data == 7 ASSERT update.update.amount == -3 +ASSERT update.objectMessage == msg ``` --- @@ -164,7 +166,7 @@ ASSERT counter.data == 25 | RTLC8c | Merge initial value via RTLC16 | | RTLC16a | Add counterCreate.count to data | | RTLC16b | Set createOperationIsMerged to true | -| RTLC16c | Return LiveCounterUpdate with amount = count | +| RTLC16c | Return LiveCounterUpdate with amount = count and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -182,6 +184,7 @@ update = counter.applyOperation(msg, source: CHANNEL) ASSERT counter.data == 42 ASSERT counter.createOperationIsMerged == true ASSERT update.update.amount == 42 +ASSERT update.objectMessage == msg ``` --- @@ -441,9 +444,13 @@ ASSERT result == true | Spec | Requirement | |------|-------------| | RTLO5b | Tombstone the LiveObject | +| RTLO5c | Return the LiveObjectUpdate returned by tombstone | | RTLO4e2 | Set isTombstone to true | | RTLO4e4 | Set data to zero-value | -| RTLC7d4a | Emit LiveCounterUpdate with negated previous value | +| RTLO4e5 | Compute diff for the tombstone update | +| RTLO4e6 | Set tombstone flag on the update | +| RTLO4e7 | Set objectMessage on the update | +| RTLC7d4c | Emit LiveCounterUpdate returned by RTLO5 | ### Setup ```pseudo @@ -464,6 +471,8 @@ ASSERT counter.isTombstone == true ASSERT counter.data == 0 ASSERT counter.tombstonedAt == 1700000000000 ASSERT update.update.amount == -42 +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg ``` --- @@ -588,7 +597,7 @@ ASSERT counter.data == 0 | RTLC6a | Replace siteTimeserials from ObjectState | | RTLC6b | Set createOperationIsMerged to false | | RTLC6c | Set data to counter.count | -| RTLC6h | Return diff as LiveCounterUpdate | +| RTLC6h | Return diff as LiveCounterUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -612,6 +621,7 @@ ASSERT counter.data == 50 ASSERT counter.siteTimeserials == { "site2": "05" } ASSERT counter.createOperationIsMerged == false ASSERT update.update.amount == 40 +ASSERT update.objectMessage == state_msg ``` --- @@ -644,6 +654,7 @@ update = counter.replaceData(state_msg) ASSERT counter.data == 150 ASSERT counter.createOperationIsMerged == true ASSERT update.update.amount == 150 +ASSERT update.objectMessage == state_msg ``` --- @@ -684,8 +695,10 @@ ASSERT update.noop == true | Spec | Requirement | |------|-------------| -| RTLC6f | If ObjectState.tombstone is true, tombstone the counter | -| RTLC6f1 | Return LiveCounterUpdate with amount = negated previous data | +| RTLC6f | If ObjectState.tombstone is true, tombstone the counter via LiveObject.tombstone | +| RTLC6f2 | Return the LiveCounterUpdate returned by LiveObject.tombstone | +| RTLO4e6 | Tombstone flag set on the update | +| RTLO4e7 | objectMessage set on the update | ### Setup ```pseudo @@ -707,6 +720,8 @@ update = counter.replaceData(state_msg) ASSERT counter.isTombstone == true ASSERT counter.data == 0 ASSERT update.update.amount == -30 +ASSERT update.tombstone == true +ASSERT update.objectMessage == state_msg ``` --- @@ -735,6 +750,7 @@ update = counter.replaceData(state_msg) ```pseudo ASSERT counter.data == 0 ASSERT update.update.amount == -42 +ASSERT update.objectMessage == state_msg ``` --- @@ -762,6 +778,7 @@ update = counter.replaceData(state_msg) ### Assertions ```pseudo ASSERT update.update.amount == 55 +ASSERT update.objectMessage == state_msg ``` --- diff --git a/uts/objects/unit/live_counter_api.md b/uts/objects/unit/live_counter_api.md index 2b5e733e9..f6bca2a1a 100644 --- a/uts/objects/unit/live_counter_api.md +++ b/uts/objects/unit/live_counter_api.md @@ -23,6 +23,8 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct |------|-------------| | RTLC5c | Returns current data value | +Note: RTLC5a and RTLC5b have been replaced by RTO25. The access API preconditions (OBJECT_SUBSCRIBE mode check and channel state check) are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -36,16 +38,6 @@ ASSERT counter.value() == 100 --- -## RTLC5a - value() requires OBJECT_SUBSCRIBE mode - -**Test ID**: `objects/unit/RTLC5a/value-requires-subscribe-0` - -**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. - -This is implicitly tested by `setup_synced_channel` which always includes OBJECT_SUBSCRIBE. A negative test would use a channel without OBJECT_SUBSCRIBE and verify the error. - ---- - ## RTLC12 - increment sends v6 COUNTER_INC message **Test ID**: `objects/unit/RTLC12/increment-sends-counter-inc-0` @@ -124,87 +116,11 @@ ASSERT root.get("score").value() == 150 --- -## RTLC12b - increment requires OBJECT_PUBLISH mode +## RTLC12b/c/d - increment write preconditions (replaced by RTO26) **Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` -**Spec requirement:** Requires OBJECT_PUBLISH channel mode. - -### Setup -```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", - flags: HAS_OBJECTS, modes: ["OBJECT_SUBSCRIBE"] - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.increment(10) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40024 -``` - ---- - -## RTLC12d - increment with echoMessages false throws - -**Test ID**: `objects/unit/RTLC12d/echo-messages-false-0` - -**Spec requirement:** If echoMessages is false, throw 40000. - -### Setup -```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", - flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key", echoMessages: false }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() -``` - -### Test Steps -```pseudo -AWAIT root.increment(10) FAILS WITH error -``` - -### Assertions -```pseudo -ASSERT error.code == 40000 -``` +Note: RTLC12b, RTLC12c, and RTLC12d have been replaced by RTO26. The write API preconditions (OBJECT_PUBLISH mode check, channel state check, and echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. --- diff --git a/uts/objects/unit/live_map.md b/uts/objects/unit/live_map.md index a930c17a3..0186570bb 100644 --- a/uts/objects/unit/live_map.md +++ b/uts/objects/unit/live_map.md @@ -1,13 +1,13 @@ # LiveMap Tests -Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO5`, `RTLO6` +Spec points: `RTLM1`–`RTLM9`, `RTLM14`–`RTLM16`, `RTLM18`–`RTLM19`, `RTLM22`–`RTLM25`, `RTLO3`, `RTLO4a`, `RTLO4e`, `RTLO4g`, `RTLO4h`, `RTLO5`, `RTLO6` ## Test Type Unit test — pure data structure, no mocks required. ## Purpose -Tests the `LiveMap` LWW-map CRDT data structure. LiveMap holds a dictionary of `ObjectsMapEntry` values with entry-level last-write-wins semantics, supports set/remove/clear operations, create operations (initial entries merge), data replacement during sync, tombstoning, GC of tombstoned entries, and diff calculation. +Tests the `LiveMap` LWW-map CRDT data structure. LiveMap holds a dictionary of `ObjectsMapEntry` values with entry-level last-write-wins semantics, supports set/remove/clear operations, create operations (initial entries merge), data replacement during sync, tombstoning, GC of tombstoned entries, diff calculation, and parentReferences maintenance. Tests operate directly on LiveMap by calling `applyOperation()` and `replaceData()` with constructed messages. @@ -49,7 +49,7 @@ ASSERT map.siteTimeserials == {} | Spec | Requirement | |------|-------------| | RTLM7b4 | Create new ObjectsMapEntry with data and timeserial | -| RTLM7f | Return LiveMapUpdate with key set to "updated" | +| RTLM7f | Return LiveMapUpdate with key set to "updated" and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -68,6 +68,7 @@ ASSERT map.data["name"].data == { string: "Alice" } ASSERT map.data["name"].timeserial == "01" ASSERT map.data["name"].tombstone == false ASSERT update.update == { "name": "updated" } +ASSERT update.objectMessage == msg ``` --- @@ -81,6 +82,7 @@ ASSERT update.update == { "name": "updated" } | RTLM7a2e | Set data to MapSet.value | | RTLM7a2b | Set timeserial to the provided serial | | RTLM7a2c | Set tombstone to false | +| RTLM7f | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -101,6 +103,7 @@ update = map.applyOperation(msg, source: CHANNEL) ASSERT map.data["name"].data == { string: "Bob" } ASSERT map.data["name"].timeserial == "02" ASSERT update.update == { "name": "updated" } +ASSERT update.objectMessage == msg ``` --- @@ -216,6 +219,7 @@ update = map.applyOperation(msg, source: CHANNEL) ```pseudo ASSERT map.data["name"].data == { string: "Bob" } ASSERT update.update == { "name": "updated" } +ASSERT update.objectMessage == msg ``` --- @@ -288,7 +292,7 @@ ASSERT pool["counter:new@2000"].data == 0 | RTLM8a2b | Set timeserial to serial | | RTLM8a2c | Set tombstone to true | | RTLM8a2d | Set tombstonedAt via RTLO6 | -| RTLM8e | Return LiveMapUpdate with key set to "removed" | +| RTLM8e | Return LiveMapUpdate with key set to "removed" and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -311,6 +315,7 @@ ASSERT map.data["name"].tombstone == true ASSERT map.data["name"].timeserial == "02" ASSERT map.data["name"].tombstonedAt == 1700000000000 ASSERT update.update == { "name": "removed" } +ASSERT update.objectMessage == msg ``` --- @@ -324,6 +329,7 @@ ASSERT update.update == { "name": "removed" } | RTLM8b1 | Create new entry with data null and timeserial | | RTLM8b2 | Set tombstone to true | | RTLM8b3 | Set tombstonedAt via RTLO6 | +| RTLM8e | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -341,6 +347,7 @@ update = map.applyOperation(msg, source: CHANNEL) ASSERT map.data["ghost"].tombstone == true ASSERT map.data["ghost"].tombstonedAt == 1700000000000 ASSERT update.update == { "ghost": "removed" } +ASSERT update.objectMessage == msg ``` --- @@ -383,7 +390,7 @@ ASSERT update.noop == true |------|-------------| | RTLM24d | Set clearTimeserial to serial | | RTLM24e1a | Remove entries with timeserial null or < serial | -| RTLM24f | Return LiveMapUpdate with removed keys | +| RTLM24f | Return LiveMapUpdate with removed keys and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -408,6 +415,7 @@ ASSERT "old" NOT IN map.data ASSERT "same" NOT IN map.data ASSERT "new" IN map.data ASSERT update.update == { "old": "removed", "same": "removed" } +ASSERT update.objectMessage == msg ``` --- @@ -448,6 +456,7 @@ ASSERT update.noop == true | RTLM23a1 | Non-tombstoned entries merged via MAP_SET logic | | RTLM23a2 | Tombstoned entries merged via MAP_REMOVE logic | | RTLM23b | Set createOperationIsMerged to true | +| RTLM23c | Return LiveMapUpdate with merged update map and objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -472,6 +481,7 @@ ASSERT map.data["name"].data == { string: "Alice" } ASSERT map.data["removed_key"].tombstone == true ASSERT map.createOperationIsMerged == true ASSERT update.update == { "name": "updated", "removed_key": "removed" } +ASSERT update.objectMessage == msg ``` --- @@ -562,8 +572,11 @@ ASSERT map.data == {} | Spec | Requirement | |------|-------------| -| RTLM15d5a | Emit LiveMapUpdate with removed keys | +| RTLM15d5c | Emit LiveMapUpdate returned by RTLO5 | | RTLM15d5b | Return true | +| RTLO4e5 | Compute diff for the tombstone update | +| RTLO4e6 | Set tombstone flag on the update | +| RTLO4e7 | Set objectMessage on the update | ### Setup ```pseudo @@ -586,6 +599,8 @@ update = map.applyOperation(msg, source: CHANNEL) ASSERT map.isTombstone == true ASSERT map.data == {} ASSERT update.update == { "name": "removed", "age": "removed" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg ``` --- @@ -633,7 +648,7 @@ ASSERT isTombstoned(map.data["dead_ref"]) == true | RTLM6b | Set createOperationIsMerged to false | | RTLM6i | Set clearTimeserial from ObjectState.map.clearTimeserial | | RTLM6c | Set data to ObjectState.map.entries | -| RTLM6h | Return diff LiveMapUpdate | +| RTLM6h | Return diff LiveMapUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -666,6 +681,7 @@ ASSERT map.clearTimeserial == "03" ASSERT "old" NOT IN map.data ASSERT map.data["new"].data == { string: "new" } ASSERT update.update == { "old": "removed", "new": "updated" } +ASSERT update.objectMessage == state_msg ``` --- @@ -705,7 +721,7 @@ ASSERT map.data["dead"].tombstonedAt == 1700000050000 **Test ID**: `objects/unit/RTLM6d/replace-data-with-create-op-0` -**Spec requirement:** If createOp present, merge via RTLM23. +**Spec requirement:** If createOp present, merge via RTLM23, passing in the ObjectMessage. ### Setup ```pseudo @@ -742,6 +758,45 @@ ASSERT map.createOperationIsMerged == true --- +## RTLM6f - replaceData with tombstone flag tombstones map + +**Test ID**: `objects/unit/RTLM6f/replace-data-tombstone-flag-0` + +| Spec | Requirement | +|------|-------------| +| RTLM6f | If ObjectState.tombstone is true, tombstone the map via LiveObject.tombstone | +| RTLM6f2 | Return the LiveMapUpdate returned by LiveObject.tombstone | +| RTLO4e6 | Tombstone flag set on the update | +| RTLO4e7 | objectMessage set on the update | + +### Setup +```pseudo +map = LiveMap(objectId: "root", semantics: "LWW") +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +state_msg = build_object_state("root", {"site1": "01"}, { + map: { semantics: "LWW", entries: {} }, + tombstone: true +}) +update = map.replaceData(state_msg) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +ASSERT update.update == { "name": "removed" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == state_msg +``` + +--- + ## RTLM19 - GC removes tombstoned entries past grace period **Test ID**: `objects/unit/RTLM19/gc-tombstoned-entries-0` @@ -922,6 +977,7 @@ ASSERT map.get("ref") == null |------|-------------| | RTLM7a2c | Set tombstone to false | | RTLM7a2d | Set tombstonedAt to null | +| RTLM7f | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | ### Setup ```pseudo @@ -943,6 +999,7 @@ ASSERT map.data["name"].data == { string: "Alice" } ASSERT map.data["name"].tombstone == false ASSERT map.data["name"].tombstonedAt == null ASSERT update.update == { "name": "updated" } +ASSERT update.objectMessage == msg ``` --- @@ -977,4 +1034,345 @@ ASSERT map.data["after"].data == { string: "b" } ASSERT "before" IN update.update ASSERT "no_ts" IN update.update ASSERT "after" NOT IN update.update +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7a3, RTLM7g2 - parentReferences: MAP_SET overwrites entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM7a3/map-set-overwrite-objectid-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a3a | Before overwriting, check if existing entry has objectId | +| RTLM7a3b | If old entry references a LiveObject, call removeParentReference on old child | +| RTLM7g2 | After setting new objectId value, call addParentReference on new child | + +Tests that when MAP_SET overwrites an entry whose value is a LiveObject with a new LiveObject value, removeParentReference is called on the old child and addParentReference is called on the new child. + +### Setup +```pseudo +pool = ObjectsPool() +old_counter = LiveCounter(objectId: "counter:old@1000") +new_counter = LiveCounter(objectId: "counter:new@2000") +pool["counter:old@1000"] = old_counter +pool["counter:new@2000"] = new_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:old@1000" }, timeserial: "01", tombstone: false } +} +// Simulate existing parentReference +old_counter.parentReferences = { "root": {"ref"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "ref", { objectId: "counter:new@2000" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ref"].data == { objectId: "counter:new@2000" } +// removeParentReference was called on the old child +ASSERT "root" NOT IN old_counter.parentReferences OR "ref" NOT IN old_counter.parentReferences["root"] +// addParentReference was called on the new child +ASSERT "root" IN new_counter.parentReferences +ASSERT "ref" IN new_counter.parentReferences["root"] +ASSERT update.update == { "ref": "updated" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7g2 - parentReferences: MAP_SET new entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM7g2/map-set-new-entry-add-parent-ref-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7g2 | After setting new objectId value, call addParentReference on the new child | + +Tests that when MAP_SET creates a new entry whose value is a LiveObject, addParentReference is called on the child. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "score", { objectId: "counter:child@1000" }, "01", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["score"].data == { objectId: "counter:child@1000" } +ASSERT "root" IN child_counter.parentReferences +ASSERT "score" IN child_counter.parentReferences["root"] +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7 - parentReferences: MAP_SET with non-LiveObject value does not affect parentReferences + +**Test ID**: `objects/unit/RTLM7/map-set-primitive-no-parent-refs-0` + +**Spec requirement:** parentReferences operations only apply when the entry value contains an objectId. Primitive values do not trigger addParentReference or removeParentReference. + +### Setup +```pseudo +pool = ObjectsPool() +old_counter = LiveCounter(objectId: "counter:old@1000") +pool["counter:old@1000"] = old_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref": { data: { objectId: "counter:old@1000" }, timeserial: "01", tombstone: false } +} +old_counter.parentReferences = { "root": {"ref"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "ref", { string: "plain_value" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["ref"].data == { string: "plain_value" } +// removeParentReference was called on old child (entry previously had objectId) +ASSERT "root" NOT IN old_counter.parentReferences OR "ref" NOT IN old_counter.parentReferences["root"] +// No addParentReference call because new value is a primitive +ASSERT update.update == { "ref": "updated" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM8a3 - parentReferences: MAP_REMOVE entry referencing LiveObject + +**Test ID**: `objects/unit/RTLM8a3/map-remove-objectid-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM8a3a | Before tombstoning, check if existing entry has objectId | +| RTLM8a3b | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when MAP_REMOVE tombstones an entry whose value is a LiveObject, removeParentReference is called on the child. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child_counter + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "score": { data: { objectId: "counter:child@1000" }, timeserial: "01", tombstone: false } +} +child_counter.parentReferences = { "root": {"score"} } +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "score", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["score"].tombstone == true +// removeParentReference was called on the child +ASSERT "root" NOT IN child_counter.parentReferences OR "score" NOT IN child_counter.parentReferences["root"] +ASSERT update.update == { "score": "removed" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM8 - parentReferences: MAP_REMOVE entry with non-LiveObject value + +**Test ID**: `objects/unit/RTLM8/map-remove-primitive-no-parent-refs-0` + +**Spec requirement:** MAP_REMOVE on a primitive-valued entry does not call removeParentReference because there is no objectId. + +### Setup +```pseudo +pool = ObjectsPool() +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +``` + +### Test Steps +```pseudo +msg = build_map_remove("root", "name", "02", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["name"].tombstone == true +ASSERT update.update == { "name": "removed" } +ASSERT update.objectMessage == msg +// No parentReference calls needed -- test passes without errors +``` + +--- + +## RTLM24e1c - parentReferences: MAP_CLEAR removes parent references for cleared entries + +**Test ID**: `objects/unit/RTLM24e1c/map-clear-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM24e1c1 | Before removing entry, check if it has objectId | +| RTLM24e1c2 | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when MAP_CLEAR removes entries that reference LiveObjects, removeParentReference is called for each. + +### Setup +```pseudo +pool = ObjectsPool() +counter_a = LiveCounter(objectId: "counter:a@1000") +counter_b = LiveCounter(objectId: "counter:b@1000") +pool["counter:a@1000"] = counter_a +pool["counter:b@1000"] = counter_b + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "ref_a": { data: { objectId: "counter:a@1000" }, timeserial: "02", tombstone: false }, + "ref_b": { data: { objectId: "counter:b@1000" }, timeserial: "02", tombstone: false }, + "primitive": { data: { string: "hello" }, timeserial: "02", tombstone: false }, + "newer": { data: { string: "kept" }, timeserial: "09", tombstone: false } +} +counter_a.parentReferences = { "root": {"ref_a"} } +counter_b.parentReferences = { "root": {"ref_b"} } +``` + +### Test Steps +```pseudo +msg = build_map_clear("root", "05", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +// ref_a and ref_b removed (timeserial "02" < "05"), newer kept (timeserial "09" > "05") +ASSERT "ref_a" NOT IN map.data +ASSERT "ref_b" NOT IN map.data +ASSERT "primitive" NOT IN map.data +ASSERT "newer" IN map.data +// removeParentReference was called on both child counters +ASSERT "root" NOT IN counter_a.parentReferences OR "ref_a" NOT IN counter_a.parentReferences["root"] +ASSERT "root" NOT IN counter_b.parentReferences OR "ref_b" NOT IN counter_b.parentReferences["root"] +ASSERT update.update == { "ref_a": "removed", "ref_b": "removed", "primitive": "removed" } +ASSERT update.objectMessage == msg +``` + +--- + +## RTLO4e9 - parentReferences: tombstone LiveMap removes parent references for all entries + +**Test ID**: `objects/unit/RTLO4e9/tombstone-map-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4e9a | Before clearing data, for each entry check if it has objectId | +| RTLO4e9b | If entry references a LiveObject, call removeParentReference on the child | + +Tests that when a LiveMap is tombstoned (via OBJECT_DELETE), removeParentReference is called for each entry that references a LiveObject before the data is cleared. + +### Setup +```pseudo +pool = ObjectsPool() +child_counter = LiveCounter(objectId: "counter:child@1000") +child_map = LiveMap(objectId: "map:child@1000", semantics: "LWW") +pool["counter:child@1000"] = child_counter +pool["map:child@1000"] = child_map + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "counter_ref": { data: { objectId: "counter:child@1000" }, timeserial: "01", tombstone: false }, + "map_ref": { data: { objectId: "map:child@1000" }, timeserial: "01", tombstone: false }, + "name": { data: { string: "Alice" }, timeserial: "01", tombstone: false } +} +map.siteTimeserials = { "site1": "00" } +child_counter.parentReferences = { "root": {"counter_ref"} } +child_map.parentReferences = { "root": {"map_ref"} } +``` + +### Test Steps +```pseudo +msg = build_object_delete("root", "01", "site1", 1700000000000) +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.isTombstone == true +ASSERT map.data == {} +// removeParentReference was called on both children +ASSERT "root" NOT IN child_counter.parentReferences OR "counter_ref" NOT IN child_counter.parentReferences["root"] +ASSERT "root" NOT IN child_map.parentReferences OR "map_ref" NOT IN child_map.parentReferences["root"] +ASSERT update.update == { "counter_ref": "removed", "map_ref": "removed", "name": "removed" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg +``` + +--- + +## RTLM7a3, RTLM7g2 - parentReferences: MAP_SET overwriting LiveObject with LiveObject calls both remove and add + +**Test ID**: `objects/unit/RTLM7a3/map-set-replace-objectid-both-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTLM7a3b | removeParentReference called on old child before overwrite | +| RTLM7g2 | addParentReference called on new child after set | + +Tests that both removeParentReference and addParentReference are called in the correct order when replacing one LiveObject reference with another. + +### Setup +```pseudo +pool = ObjectsPool() +old_map = LiveMap(objectId: "map:old@1000", semantics: "LWW") +new_map = LiveMap(objectId: "map:new@2000", semantics: "LWW") +pool["map:old@1000"] = old_map +pool["map:new@2000"] = new_map + +map = LiveMap(objectId: "root", semantics: "LWW", pool: pool) +map.data = { + "child": { data: { objectId: "map:old@1000" }, timeserial: "01", tombstone: false } +} +old_map.parentReferences = { "root": {"child"} } +``` + +### Test Steps +```pseudo +msg = build_map_set("root", "child", { objectId: "map:new@2000" }, "02", "site1") +update = map.applyOperation(msg, source: CHANNEL) +``` + +### Assertions +```pseudo +ASSERT map.data["child"].data == { objectId: "map:new@2000" } +// Old child no longer references root +ASSERT "root" NOT IN old_map.parentReferences OR "child" NOT IN old_map.parentReferences["root"] +// New child references root +ASSERT "root" IN new_map.parentReferences +ASSERT "child" IN new_map.parentReferences["root"] +ASSERT update.update == { "child": "updated" } +ASSERT update.objectMessage == msg ``` diff --git a/uts/objects/unit/live_map_api.md b/uts/objects/unit/live_map_api.md index 7a7282246..44dcb795b 100644 --- a/uts/objects/unit/live_map_api.md +++ b/uts/objects/unit/live_map_api.md @@ -1,6 +1,6 @@ # LiveMap API Tests -Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24` +Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24`, `RTLMV4`, `RTLCV4` ## Test Type Unit test with mocked WebSocket client @@ -19,7 +19,11 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct **Test ID**: `objects/unit/RTLM5/get-string-value-0` -**Spec requirement:** Returns value at key, resolved per RTLM5d2. +| Spec | Requirement | +|------|-------------| +| RTLM5d2 | Returns value at key, resolved per RTLM5d2 | + +Note: RTLM5b and RTLM5c have been replaced by RTO25. The access API preconditions (OBJECT_SUBSCRIBE mode check and channel state check) are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. ### Setup ```pseudo @@ -76,7 +80,11 @@ ASSERT root.get("profile").get("email").value() == "alice@example.com" **Test ID**: `objects/unit/RTLM10/size-non-tombstoned-0` -**Spec requirement:** Returns number of non-tombstoned entries. +| Spec | Requirement | +|------|-------------| +| RTLM10d | Returns number of non-tombstoned entries | + +Note: RTLM10b and RTLM10c have been replaced by RTO25. The access API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. ### Setup ```pseudo @@ -94,7 +102,11 @@ ASSERT root.size() == 7 **Test ID**: `objects/unit/RTLM11/entries-yields-pairs-0` -**Spec requirement:** Returns non-tombstoned key-value pairs. +| Spec | Requirement | +|------|-------------| +| RTLM11d | Returns non-tombstoned key-value pairs | + +Note: RTLM11b and RTLM11c have been replaced by RTO25. The access API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto25_access_preconditions.md`. ### Setup ```pseudo @@ -150,10 +162,15 @@ ASSERT "name" IN keys | Spec | Requirement | |------|-------------| +| RTLM20a3 | value parameter accepts Boolean, Binary, Number, String, JsonArray, JsonObject, LiveCounterValueType, or LiveMapValueType | +| RTLM20e1 | Validates key and value per RTLMV4b and RTLMV4c | | RTLM20e2 | action set to MAP_SET | | RTLM20e3 | objectId set to LiveMap's objectId | | RTLM20e6 | mapSet.key set | | RTLM20e7c | mapSet.value.string for string value | +| RTLM20h2 | For non-value-type values, MAP_SET ObjectMessage is passed as single element | + +Note: RTLM20b, RTLM20c, and RTLM20d have been replaced by RTO26. The write API preconditions (OBJECT_PUBLISH mode check, channel state check, and echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. ### Setup ```pseudo @@ -232,15 +249,15 @@ ASSERT captured_messages[2].state[0].operation.mapSet.value.json == {"nested": t --- -## RTLM20e7g - set() with LiveCounterValueType consumes and sends create + set +## RTLM20e7g - set() with LiveCounterValueType generates COUNTER_CREATE + MAP_SET **Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` | Spec | Requirement | |------|-------------| -| RTLM20e7g1 | Consume value type to generate COUNTER_CREATE | -| RTLM20e7g2 | Set mapSet.value.objectId to the created objectId | -| RTLM20h1 | Array: CREATE messages then MAP_SET | +| RTLM20e7g1 | Evaluate LiveCounterValueType per RTLCV4 to generate COUNTER_CREATE ObjectMessage | +| RTLM20e7g2 | Set mapSet.value.objectId to the objectId from the generated ObjectMessage | +| RTLM20h1 | Array contains *_CREATE ObjectMessages followed by MAP_SET ObjectMessage | ### Setup ```pseudo @@ -266,100 +283,125 @@ ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId --- -## RTLM21 - remove() sends MAP_REMOVE message +## RTLM20e7g - set() with LiveMapValueType generates nested CREATE messages + MAP_SET -**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` +**Test ID**: `objects/unit/RTLM20e7g/set-map-value-type-0` | Spec | Requirement | |------|-------------| -| RTLM21e2 | action set to MAP_REMOVE | -| RTLM21e5 | mapRemove.key set | +| RTLM20e7g1 | Evaluate LiveMapValueType per RTLMV4 to generate ordered list of ObjectMessages | +| RTLM20e7g2 | Set mapSet.value.objectId to the objectId from the final ObjectMessage in the list | +| RTLM20h1 | Array contains *_CREATE ObjectMessages followed by MAP_SET ObjectMessage | ### Setup ```pseudo captured_messages = [] -// (same mock setup as above) +// (same mock setup as RTLM20 set-sends-map-set-0, capturing OBJECT messages) ``` ### Test Steps ```pseudo -AWAIT root.remove("name") +AWAIT root.set("nested_map", LiveMap.create({ "key1": "value1" })) ``` ### Assertions ```pseudo -obj_msg = captured_messages[0].state[0] -ASSERT obj_msg.operation.action == "MAP_REMOVE" -ASSERT obj_msg.operation.objectId == "root" -ASSERT obj_msg.operation.mapRemove.key == "name" +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +ASSERT state.length == 2 +ASSERT state[0].operation.action == "MAP_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "map:" +ASSERT state[1].operation.action == "MAP_SET" +ASSERT state[1].operation.mapSet.key == "nested_map" +ASSERT state[1].operation.mapSet.value.objectId == state[0].operation.objectId ``` --- -## RTLM20d - set() with echoMessages false throws +## RTLM20h1 - set() with nested LiveMapValueType containing LiveCounterValueType -**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` +**Test ID**: `objects/unit/RTLM20h1/set-nested-value-types-0` + +| Spec | Requirement | +|------|-------------| +| RTLM20h1 | Array contains all *_CREATE ObjectMessages followed by MAP_SET | +| RTLMV4d1 | Nested LiveCounterValueType is evaluated per RTLCV4 | +| RTLMV4d2 | Nested LiveMapValueType is recursively evaluated per RTLMV4 | -**Spec requirement:** If echoMessages is false, throw 40000. +Tests that when a LiveMapValueType contains a nested LiveCounterValueType, all CREATE messages appear before the MAP_SET in depth-first order. ### Setup ```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", flags: HAS_OBJECTS - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key", echoMessages: false }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() +captured_messages = [] +// (same mock setup as RTLM20 set-sends-map-set-0, capturing OBJECT messages) ``` ### Test Steps ```pseudo -AWAIT root.set("name", "Bob") FAILS WITH error +AWAIT root.set("stats", LiveMap.create({ + "count": LiveCounter.create(0), + "label": "test" +})) ``` ### Assertions ```pseudo -ASSERT error.code == 40000 +ASSERT captured_messages.length == 1 +state = captured_messages[0].state +# Expect: COUNTER_CREATE, MAP_CREATE, MAP_SET (depth-first, then the MAP_SET at root) +ASSERT state.length == 3 +ASSERT state[0].operation.action == "COUNTER_CREATE" +ASSERT state[0].operation.objectId STARTS WITH "counter:" +ASSERT state[1].operation.action == "MAP_CREATE" +ASSERT state[1].operation.objectId STARTS WITH "map:" +ASSERT state[2].operation.action == "MAP_SET" +ASSERT state[2].operation.mapSet.key == "stats" +ASSERT state[2].operation.mapSet.value.objectId == state[1].operation.objectId ``` --- -## RTLM21d - remove() with echoMessages false throws +## RTLM21 - remove() sends MAP_REMOVE message -**Test ID**: `objects/unit/RTLM21d/echo-messages-false-0` +**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` -**Spec requirement:** Same as RTLM20d for remove. +| Spec | Requirement | +|------|-------------| +| RTLM21e1 | Validates key per RTLMV4b | +| RTLM21e2 | action set to MAP_REMOVE | +| RTLM21e5 | mapRemove.key set | + +Note: RTLM21b, RTLM21c, and RTLM21d have been replaced by RTO26. The write API preconditions are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. ### Setup ```pseudo -// Same echoMessages: false setup as above +captured_messages = [] +// (same mock setup as above) ``` ### Test Steps ```pseudo -AWAIT root.remove("name") FAILS WITH error +AWAIT root.remove("name") ``` ### Assertions ```pseudo -ASSERT error.code == 40000 +obj_msg = captured_messages[0].state[0] +ASSERT obj_msg.operation.action == "MAP_REMOVE" +ASSERT obj_msg.operation.objectId == "root" +ASSERT obj_msg.operation.mapRemove.key == "name" ``` --- +## RTLM20d/RTLM21d - set()/remove() write preconditions (replaced by RTO26) + +**Test ID**: `objects/unit/RTLM20d/echo-messages-false-0` + +Note: RTLM20d and RTLM21d have been replaced by RTO26. The write API preconditions (including the echoMessages check) are now the caller's responsibility and are tested separately in `objects/unit/rto26_write_preconditions.md`. + +--- + ## RTLM20 - set() applies locally after ACK **Test ID**: `objects/unit/RTLM20/set-applies-locally-0` @@ -414,7 +456,10 @@ ASSERT obj_msg.operation.objectId == "root" **Test ID**: `objects/unit/RTLM20/set-invalid-values-table-0` -**Spec requirement:** set() rejects values of unsupported types with error 40013. +| Spec | Requirement | +|------|-------------| +| RTLM20e1 | Validates value per RTLMV4c | +| RTLMV4c | Unsupported value types throw error 40013 | ### Setup ```pseudo diff --git a/uts/objects/unit/live_object_subscribe.md b/uts/objects/unit/live_object_subscribe.md index 5f8398e87..7911e852e 100644 --- a/uts/objects/unit/live_object_subscribe.md +++ b/uts/objects/unit/live_object_subscribe.md @@ -1,6 +1,6 @@ # LiveObject Subscribe Tests -Spec points: `RTLO4b`, `RTLO4c` +Spec points: `RTLO4b`, `RTLO4b3`, `RTLO4b4c1`, `RTLO4b4c3a`, `RTLO4b4c3c`, `RTLO4b4d`, `RTLO4b4e`, `RTLO4b6`, `RTLO4b7` ## Test Type Unit test with mocked WebSocket client @@ -22,7 +22,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct | Spec | Requirement | |------|-------------| | RTLO4b3 | User provides listener for data updates | -| RTLO4b4c2 | Listener called with LiveObjectUpdate | +| RTLO4b4c3a | Registered listeners called with LiveObjectUpdate | | RTLO4b7 | Returns Subscription object | ### Setup @@ -49,50 +49,45 @@ ASSERT updates.length == 1 --- -## RTLO4b4c1 - noop update does not trigger listener +## RTLO4b7 - subscribe returns Subscription with unsubscribe method -**Test ID**: `objects/unit/RTLO4b4c1/noop-no-trigger-0` +**Test ID**: `objects/unit/RTLO4b7/subscribe-returns-subscription-0` -**Spec requirement:** If LiveObjectUpdate is a noop, do nothing. +| Spec | Requirement | +|------|-------------| +| RTLO4b7 | Returns a Subscription object | + +Tests that `subscribe` returns a `Subscription` object that has an `unsubscribe` method. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -updates = [] instance = root.get("score").instance() -instance.subscribe((event) => updates.append(event)) ``` ### Test Steps ```pseudo -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 5, "01", "remote") -])) -poll_until(updates.length >= 1, timeout: 5s) - -mock_ws.send_to_client(build_object_message("test", [ - ObjectMessage( - serial: "01", siteCode: "remote", - operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } - ) -])) +sub = instance.subscribe((event) => {}) ``` ### Assertions ```pseudo -ASSERT updates.length == 1 +ASSERT sub IS Subscription +ASSERT sub.unsubscribe IS Function ``` --- -## RTLO4c - unsubscribe deregisters listener +## RTLO4b7 - Subscription#unsubscribe stops delivery -**Test ID**: `objects/unit/RTLO4c/unsubscribe-deregisters-0` +**Test ID**: `objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0` | Spec | Requirement | |------|-------------| -| RTLO4c3 | Once deregistered, subsequent updates do not call listener | -| RTLO4c4 | No side effects on channel or RealtimeObject | +| RTLO4b7 | Returns a Subscription object | +| RTLO4b4c3a | Registered listeners called with LiveObjectUpdate | + +Tests that calling `unsubscribe()` on the returned `Subscription` deregisters the listener so that subsequent updates do not trigger it. ### Setup ```pseudo @@ -123,45 +118,64 @@ ASSERT updates.length == 1 --- -## RTLO4b1 - subscribe requires OBJECT_SUBSCRIBE mode +## RTLO4b7 - Subscription#unsubscribe is idempotent -**Test ID**: `objects/unit/RTLO4b1/subscribe-requires-mode-0` +**Test ID**: `objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0` -**Spec requirement:** Requires OBJECT_SUBSCRIBE channel mode per RTO2. +**Spec requirement:** Calling `Subscription#unsubscribe()` multiple times must not throw or produce errors. ### Setup ```pseudo -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success( - ProtocolMessage(action: CONNECTED, connectionDetails: { - connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", - objectsGCGracePeriod: 86400000 - }) - ), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", - flags: HAS_OBJECTS, modes: ["OBJECT_PUBLISH"] - )) - mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) - } -) -install_mock(mock_ws) -client = Realtime(options: { key: "fake:key" }) -channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) -root = AWAIT channel.object.get() +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") instance = root.get("score").instance() +sub = instance.subscribe((event) => {}) ``` ### Test Steps ```pseudo -instance.subscribe((event) => {}) FAILS WITH error +sub.unsubscribe() +sub.unsubscribe() ``` ### Assertions ```pseudo -ASSERT error.code == 40024 +// No error thrown — both calls complete without error +``` + +--- + +## RTLO4b4c1 - noop update does not trigger listener + +**Test ID**: `objects/unit/RTLO4b4c1/noop-no-trigger-0` + +**Spec requirement:** If LiveObjectUpdate is a noop, do nothing. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "01", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + ObjectMessage( + serial: "01", siteCode: "remote", + operation: { action: "COUNTER_INC", objectId: "counter:score@1000", counterInc: {} } + ) +])) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 ``` --- @@ -220,25 +234,153 @@ ASSERT updates.length == 1 --- -## RTLO4c1 - unsubscribe requires no channel mode +## RTLO4b4c3c - tombstone update deregisters all LiveObject#subscribe listeners -**Test ID**: `objects/unit/RTLO4c1/unsubscribe-no-mode-required-0` +**Test ID**: `objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4c3c | If LiveObjectUpdate.tombstone is true, deregister all LiveObject#subscribe listeners | +| RTLO4b4c3a | Listeners are called with the tombstone update itself before deregistration | -**Spec requirement:** Does not require any specific channel modes. +Tests that when a tombstone update is emitted, all registered listeners are called with the tombstone update, but subsequent updates do not fire any listener because they have been deregistered. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates_a = [] +updates_b = [] instance = root.get("score").instance() -sub = instance.subscribe((event) => {}) +instance.subscribe((event) => updates_a.append(event)) +instance.subscribe((event) => updates_b.append(event)) ``` ### Test Steps ```pseudo -sub.unsubscribe() +# Send an OBJECT_DELETE which causes a tombstone LiveObjectUpdate +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "50", "remote") +])) +poll_until(updates_a.length >= 1, timeout: 5s) + +# Both listeners should have received the tombstone update +ASSERT updates_a.length == 1 +ASSERT updates_a[0].tombstone == true +ASSERT updates_b.length == 1 +ASSERT updates_b[0].tombstone == true + +# Send another update — listeners should have been deregistered by tombstone +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 3, "51", "remote") +])) +``` + +### Assertions +```pseudo +ASSERT updates_a.length == 1 +ASSERT updates_b.length == 1 +``` + +--- + +## RTLO4b4d - LiveObjectUpdate.objectMessage is populated from source ObjectMessage + +**Test ID**: `objects/unit/RTLO4b4d/update-has-object-message-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4d | LiveObjectUpdate.objectMessage is the source ObjectMessage that caused the update | + +Tests that when an update is triggered by an incoming ObjectMessage, the `LiveObjectUpdate.objectMessage` field is populated with that source ObjectMessage. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -// No error thrown +ASSERT updates.length == 1 +ASSERT updates[0].objectMessage IS NOT null +ASSERT updates[0].objectMessage.serial == "99" +ASSERT updates[0].objectMessage.siteCode == "remote" +ASSERT updates[0].objectMessage.operation.action == "COUNTER_INC" +ASSERT updates[0].objectMessage.operation.objectId == "counter:score@1000" +``` + +--- + +## RTLO4b4e - LiveObjectUpdate.tombstone is true for tombstone updates + +**Test ID**: `objects/unit/RTLO4b4e/tombstone-flag-true-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4b4e | LiveObjectUpdate.tombstone indicates the update was emitted as a result of tombstoning | + +Tests that when a `LiveObject` is tombstoned (e.g. via OBJECT_DELETE), the emitted `LiveObjectUpdate` has `tombstone == true`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_object_delete("counter:score@1000", "50", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +ASSERT updates[0].tombstone == true +``` + +--- + +## RTLO4b4e - LiveObjectUpdate.tombstone is false for normal updates + +**Test ID**: `objects/unit/RTLO4b4e/tombstone-flag-false-0` + +**Spec requirement:** LiveObjectUpdate.tombstone defaults to false if not explicitly set. + +Tests that for a normal (non-tombstone) update, `LiveObjectUpdate.tombstone` is `false`. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +updates = [] +instance = root.get("score").instance() +instance.subscribe((event) => updates.append(event)) +``` + +### Test Steps +```pseudo +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(updates.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT updates.length == 1 +ASSERT updates[0].tombstone == false ``` diff --git a/uts/objects/unit/objects_pool.md b/uts/objects/unit/objects_pool.md index 214fe7db0..cbbf400ed 100644 --- a/uts/objects/unit/objects_pool.md +++ b/uts/objects/unit/objects_pool.md @@ -82,7 +82,7 @@ ASSERT pool.syncState == SYNCING |------|-------------| | RTO4b1 | Remove all objects except root | | RTO4b2 | Clear root LiveMap data to zero-value | -| RTO4b2a | Emit LiveMapUpdate for root with removed entries | +| RTO4b2a | Emit LiveMapUpdate for root with removed entries, without populating objectMessage | | RTO4b4 | Perform sync completion actions | ### Setup @@ -114,6 +114,7 @@ ASSERT "root" IN pool ASSERT pool["root"].data == {} ASSERT updates.length >= 1 ASSERT updates[0].update == { "name": "removed" } +ASSERT updates[0].objectMessage IS null ``` --- @@ -908,3 +909,225 @@ ASSERT pool.syncState == SYNCED ASSERT "old_key" NOT IN pool["root"].data ASSERT pool["root"].data["new_key"].data == { string: "new" } ``` + +--- + +## RTO5c10 - Sync completion rebuilds parentReferences + +**Test ID**: `objects/unit/RTO5c10/sync-rebuilds-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10 | Rebuild every parentReferences map after sync completion | +| RTO5c10a | For each LiveObject in ObjectsPool, reset parentReferences to empty map (RTLO3f2) | +| RTO5c10b | For each LiveMap, iterate entries (RTLM11); for each entry whose value is a LiveObject, call addParentReference(parent, key) per RTLO4g | + +Tests that after a normal sync, each LiveObject in the pool has correct parentReferences matching its position in the synced tree. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" }, + "name": { data: { string: "Alice" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:score@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }), + build_object_state("map:profile@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "nested_counter": { data: { objectId: "counter:nested@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:nested@1000", {"aaa": "t:0"}, { + counter: { count: 5 }, + createOp: { counterCreate: { count: 5 } } + }) +])) +``` + +### Assertions +```pseudo +# root is not referenced by any parent +ASSERT pool["root"].parentReferences == {} + +# counter:score@1000 is referenced by root at key "score" +ASSERT pool["counter:score@1000"].parentReferences == { "root": {"score"} } + +# map:profile@1000 is referenced by root at key "profile" +ASSERT pool["map:profile@1000"].parentReferences == { "root": {"profile"} } + +# counter:nested@1000 is referenced by map:profile@1000 at key "nested_counter" +ASSERT pool["counter:nested@1000"].parentReferences == { "map:profile@1000": {"nested_counter"} } + +# Primitive-valued entries ("name") do not appear in any parentReferences +``` + +--- + +## RTO5c10 - Re-sync rebuilds parentReferences with new tree structure + +**Test ID**: `objects/unit/RTO5c10/resync-rebuilds-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | Reset parentReferences to empty map before rebuilding | +| RTO5c10b | Rebuild from current LiveMap entries after sync completion | + +Tests that after a second sync sequence with a different tree structure, parentReferences are reset then rebuilt to reflect the new tree, not the old one. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) + +# First sync: counter:abc@1000 is a child of root +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "counter_key": { data: { objectId: "counter:abc@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 10 }, + createOp: { counterCreate: { count: 10 } } + }) +])) + +# Verify first sync parentReferences +ASSERT pool["counter:abc@1000"].parentReferences == { "root": {"counter_key"} } +``` + +### Test Steps +```pseudo +# Second sync: counter:abc@1000 is now a child of map:wrapper@1000, not root +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync2:", [ + build_object_state("root", {"aaa": "t:1"}, { + map: { + semantics: "LWW", + entries: { + "wrapper": { data: { objectId: "map:wrapper@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("map:wrapper@1000", {"aaa": "t:1"}, { + map: { + semantics: "LWW", + entries: { + "moved_counter": { data: { objectId: "counter:abc@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:1"}, { + counter: { count: 20 }, + createOp: { counterCreate: { count: 20 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# root is not referenced by any parent +ASSERT pool["root"].parentReferences == {} + +# map:wrapper@1000 is now a child of root at key "wrapper" +ASSERT pool["map:wrapper@1000"].parentReferences == { "root": {"wrapper"} } + +# counter:abc@1000 is now a child of map:wrapper@1000, NOT of root +ASSERT pool["counter:abc@1000"].parentReferences == { "map:wrapper@1000": {"moved_counter"} } +``` + +--- + +## RTO5c10 - Empty sync leaves root with empty parentReferences + +**Test ID**: `objects/unit/RTO5c10/empty-sync-parent-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | Reset parentReferences to empty map | +| RTO4b | ATTACHED without HAS_OBJECTS performs immediate sync completion | + +Tests that after an empty sync (no HAS_OBJECTS flag), root has empty parentReferences because there are no children to reference it. + +### Setup +```pseudo +pool = ObjectsPool() + +# First, do a normal sync to populate parentReferences +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "child": { data: { objectId: "counter:child@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:child@1000", {"aaa": "t:0"}, { + counter: { count: 1 }, + createOp: { counterCreate: { count: 1 } } + }) +])) + +# Verify parentReferences are populated after first sync +ASSERT pool["counter:child@1000"].parentReferences == { "root": {"child"} } +``` + +### Test Steps +```pseudo +# Empty sync: ATTACHED without HAS_OBJECTS +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", flags: 0 +)) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# counter:child@1000 was removed from pool (RTO4b1) +ASSERT "counter:child@1000" NOT IN pool + +# root exists with empty data and empty parentReferences +ASSERT "root" IN pool +ASSERT pool["root"].data == {} +ASSERT pool["root"].parentReferences == {} +``` diff --git a/uts/objects/unit/parent_references.md b/uts/objects/unit/parent_references.md new file mode 100644 index 000000000..33d4d74e6 --- /dev/null +++ b/uts/objects/unit/parent_references.md @@ -0,0 +1,734 @@ +# Parent References Tests + +Spec points: `RTLO3f`, `RTLO4g`, `RTLO4h`, `RTLO4f`, `RTO5c10` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `parentReferences` tracking on `LiveObject`, the `addParentReference` and `removeParentReference` methods, the `getFullPaths` graph traversal, and the post-sync rebuild of parentReferences by the ObjectsPool. + +`parentReferences` is a `Dict>` keyed by parent LiveMap objectId, with each value being the set of keys at which that LiveMap references this LiveObject. These references allow `getFullPaths` to determine every key-path from root to a given object in the LiveObjects graph. + +Tests operate directly on LiveObject/LiveCounter/LiveMap instances and on ObjectsPool for the post-sync rebuild tests. + +## Shared Helpers + +See `helpers/standard_test_pool.md` for builder functions and STANDARD_POOL_OBJECTS. + +--- + +## RTLO3f2 - parentReferences initialized to empty map on LiveCounter + +**Test ID**: `objects/unit/RTLO3f2/init-empty-counter-0` + +**Spec requirement:** parentReferences is set to an empty map when the LiveObject is initialized. + +### Setup +```pseudo +counter = LiveCounter(objectId: "counter:abc@1000") +``` + +### Assertions +```pseudo +ASSERT counter.parentReferences == {} +``` + +--- + +## RTLO3f2 - parentReferences initialized to empty map on LiveMap + +**Test ID**: `objects/unit/RTLO3f2/init-empty-map-0` + +**Spec requirement:** parentReferences is set to an empty map when the LiveObject is initialized. + +### Setup +```pseudo +map = LiveMap(objectId: "map:abc@1000", semantics: "LWW") +``` + +### Assertions +```pseudo +ASSERT map.parentReferences == {} +``` + +--- + +## RTLO4g2 - addParentReference creates new entry for first reference + +**Test ID**: `objects/unit/RTLO4g2/first-reference-new-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4g2 | If parentReferences does not contain an entry for parent.objectId, insert a new entry with a set containing only key | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT "map:parent@1000" IN child.parentReferences +ASSERT child.parentReferences["map:parent@1000"] == {"score"} +``` + +--- + +## RTLO4g1 - addParentReference adds key to existing entry for same parent + +**Test ID**: `objects/unit/RTLO4g1/second-key-same-parent-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4g1 | If parentReferences already contains an entry for parent.objectId, add key to that entry's set | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.addParentReference(parent, "points") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"score", "points"} +``` + +--- + +## RTLO4g - addParentReference with different parent creates separate entry + +**Test ID**: `objects/unit/RTLO4g/different-parent-separate-entry-0` + +**Spec requirement:** Each parent LiveMap gets its own entry in parentReferences. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +parent_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent_a, "x") +child.addParentReference(parent_b, "y") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:a@1000"] == {"x"} +ASSERT child.parentReferences["map:b@1000"] == {"y"} +``` + +--- + +## RTLO4g - addParentReference with multiple parents and multiple keys + +**Test ID**: `objects/unit/RTLO4g/multiple-parents-multiple-keys-0` + +**Spec requirement:** parentReferences correctly tracks multiple keys across multiple parents. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +parent_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.addParentReference(parent_a, "x") +child.addParentReference(parent_a, "y") +child.addParentReference(parent_b, "p") +child.addParentReference(parent_b, "q") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:a@1000"] == {"x", "y"} +ASSERT child.parentReferences["map:b@1000"] == {"p", "q"} +``` + +--- + +## RTLO4h1 - removeParentReference no-op for non-existent parent + +**Test ID**: `objects/unit/RTLO4h1/nonexistent-parent-noop-0` + +**Spec requirement:** If parentReferences does not contain an entry for parent.objectId, do nothing. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences == {} +``` + +--- + +## RTLO4h2 - removeParentReference removes key but leaves other keys + +**Test ID**: `objects/unit/RTLO4h2/remove-key-leaves-others-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4h2 | Remove key from that entry's set | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score", "points"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"points"} +``` + +--- + +## RTLO4h3 - removeParentReference removes entry when set becomes empty + +**Test ID**: `objects/unit/RTLO4h3/remove-last-key-removes-entry-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4h2 | Remove key from that entry's set | +| RTLO4h3 | If the entry's set is empty after removal, remove the entry from parentReferences | + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "score") +``` + +### Assertions +```pseudo +ASSERT "map:parent@1000" NOT IN child.parentReferences +ASSERT child.parentReferences == {} +``` + +--- + +## RTLO4h - removeParentReference for non-existent key in existing parent + +**Test ID**: `objects/unit/RTLO4h/remove-nonexistent-key-0` + +**Spec requirement:** Removing a key that does not exist in the parent's set does not alter the existing keys. + +### Setup +```pseudo +child = LiveCounter(objectId: "counter:child@1000") +parent = LiveMap(objectId: "map:parent@1000", semantics: "LWW") +child.parentReferences = { "map:parent@1000": {"score"} } +``` + +### Test Steps +```pseudo +child.removeParentReference(parent, "nonexistent") +``` + +### Assertions +```pseudo +ASSERT child.parentReferences["map:parent@1000"] == {"score"} +``` + +--- + +## RTLO4f2 - getFullPaths for root returns empty key-path + +**Test ID**: `objects/unit/RTLO4f2/root-returns-empty-path-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | The empty simple path (which exists only when this LiveObject is itself root) contributes the empty key-path [] | + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] +``` + +### Assertions +```pseudo +paths = root.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS [] +``` + +--- + +## RTLO4f - getFullPaths for direct child of root + +**Test ID**: `objects/unit/RTLO4f/direct-child-single-path-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f1 | Graph G has directed edges from parent to child labelled with key, derived from parentReferences | +| RTLO4f2 | Each simple path from root to this LiveObject contributes one key-path | + +Tests that a LiveObject referenced directly from root at key "score" returns [["score"]]. + +### Setup +```pseudo +pool = ObjectsPool() +counter = LiveCounter(objectId: "counter:score@1000") +pool["counter:score@1000"] = counter + +root = pool["root"] +counter.addParentReference(root, "score") +``` + +### Assertions +```pseudo +paths = counter.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS ["score"] +``` + +--- + +## RTLO4f - getFullPaths for deeply nested object + +**Test ID**: `objects/unit/RTLO4f/deep-nesting-0` + +**Spec requirement:** getFullPaths traverses multiple levels of parentReferences to find all key-paths from root. + +Tests the path root --"profile"--> map:profile --"prefs"--> map:prefs --"theme_counter"--> counter:theme. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +profile = LiveMap(objectId: "map:profile@1000", semantics: "LWW") +pool["map:profile@1000"] = profile +profile.addParentReference(root, "profile") + +prefs = LiveMap(objectId: "map:prefs@1000", semantics: "LWW") +pool["map:prefs@1000"] = prefs +prefs.addParentReference(profile, "prefs") + +theme_counter = LiveCounter(objectId: "counter:theme@1000") +pool["counter:theme@1000"] = theme_counter +theme_counter.addParentReference(prefs, "theme_counter") +``` + +### Assertions +```pseudo +paths = theme_counter.getFullPaths() +ASSERT paths.length == 1 +ASSERT paths CONTAINS ["profile", "prefs", "theme_counter"] +``` + +--- + +## RTLO4f - getFullPaths with multiple parents (diamond graph) + +**Test ID**: `objects/unit/RTLO4f/diamond-graph-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | Each simple path from root to this LiveObject contributes one key-path | +| RTLO4f3 | Each key-path appears exactly once; order is unspecified | + +Tests a diamond: root --"a"--> map:A --"x"--> counter:leaf, and root --"b"--> map:B --"y"--> counter:leaf. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +pool["map:a@1000"] = map_a +map_a.addParentReference(root, "a") + +map_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +pool["map:b@1000"] = map_b +map_b.addParentReference(root, "b") + +leaf = LiveCounter(objectId: "counter:leaf@1000") +pool["counter:leaf@1000"] = leaf +leaf.addParentReference(map_a, "x") +leaf.addParentReference(map_b, "y") +``` + +### Assertions +```pseudo +paths = leaf.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["a", "x"] +ASSERT paths CONTAINS ["b", "y"] +``` + +--- + +## RTLO4f - getFullPaths with single parent referencing at multiple keys + +**Test ID**: `objects/unit/RTLO4f/single-parent-multiple-keys-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | Each simple path from root contributes one key-path | +| RTLO4f3 | Each key-path appears exactly once | + +Tests that when a parent map references the same child at two different keys, two distinct key-paths are returned. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +child = LiveCounter(objectId: "counter:child@1000") +pool["counter:child@1000"] = child +child.addParentReference(root, "primary") +child.addParentReference(root, "alias") +``` + +### Assertions +```pseudo +paths = child.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["primary"] +ASSERT paths CONTAINS ["alias"] +``` + +--- + +## RTLO4f - getFullPaths for orphan returns empty list + +**Test ID**: `objects/unit/RTLO4f/orphan-returns-empty-0` + +**Spec requirement:** An object with no parentReferences path leading to root has no key-paths. + +### Setup +```pseudo +pool = ObjectsPool() + +orphan = LiveCounter(objectId: "counter:orphan@1000") +pool["counter:orphan@1000"] = orphan +``` + +### Assertions +```pseudo +paths = orphan.getFullPaths() +ASSERT paths.length == 0 +``` + +--- + +## RTLO4f - getFullPaths suppresses cycles + +**Test ID**: `objects/unit/RTLO4f/cycle-suppression-0` + +| Spec | Requirement | +|------|-------------| +| RTLO4f2 | A simple path visits each node at most once | +| RTLO4f4 | (non-normative) Typical approach skips branches that would revisit a node | + +Tests that a cycle in parentReferences does not cause infinite traversal. Graph: root --"a"--> map:A --"b"--> map:B --"a"--> map:A (cycle). The only valid simple path to map:B is ["a", "b"]. + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_a = LiveMap(objectId: "map:a@1000", semantics: "LWW") +pool["map:a@1000"] = map_a +map_a.addParentReference(root, "a") + +map_b = LiveMap(objectId: "map:b@1000", semantics: "LWW") +pool["map:b@1000"] = map_b +map_b.addParentReference(map_a, "b") + +# Create a cycle: map:A also has map:B as a parent +map_a.addParentReference(map_b, "a") +``` + +### Assertions +```pseudo +paths_b = map_b.getFullPaths() +ASSERT paths_b.length == 1 +ASSERT paths_b CONTAINS ["a", "b"] + +paths_a = map_a.getFullPaths() +ASSERT paths_a.length == 1 +ASSERT paths_a CONTAINS ["a"] +``` + +--- + +## RTLO4f - getFullPaths with complex diamond and deep nesting + +**Test ID**: `objects/unit/RTLO4f/complex-diamond-deep-0` + +**Spec requirement:** getFullPaths returns all distinct simple paths from root, including through multiple intermediate nodes. + +Tests a graph where root has two branches that converge on a deeply nested object: +- root --"left"--> map:L --"mid"--> map:M --"target"--> counter:T +- root --"right"--> map:R --"target"--> counter:T + +### Setup +```pseudo +pool = ObjectsPool() +root = pool["root"] + +map_l = LiveMap(objectId: "map:l@1000", semantics: "LWW") +pool["map:l@1000"] = map_l +map_l.addParentReference(root, "left") + +map_r = LiveMap(objectId: "map:r@1000", semantics: "LWW") +pool["map:r@1000"] = map_r +map_r.addParentReference(root, "right") + +map_m = LiveMap(objectId: "map:m@1000", semantics: "LWW") +pool["map:m@1000"] = map_m +map_m.addParentReference(map_l, "mid") + +target = LiveCounter(objectId: "counter:t@1000") +pool["counter:t@1000"] = target +target.addParentReference(map_m, "target") +target.addParentReference(map_r, "target") +``` + +### Assertions +```pseudo +paths = target.getFullPaths() +ASSERT paths.length == 2 +ASSERT paths CONTAINS ["left", "mid", "target"] +ASSERT paths CONTAINS ["right", "target"] +``` + +--- + +## RTO5c10 - Post-sync rebuild populates parentReferences from LiveMap entries + +**Test ID**: `objects/unit/RTO5c10/rebuild-from-sync-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | For each LiveObject in the ObjectsPool, reset parentReferences to empty map | +| RTO5c10b | For each LiveMap, iterate entries; for each entry whose value is a LiveObject, call addParentReference on that LiveObject | + +Tests that after a sync completes, parentReferences are rebuilt from the LiveMap entries received during sync. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "score": { data: { objectId: "counter:score@1000" }, timeserial: "t:0" }, + "profile": { data: { objectId: "map:profile@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:score@1000", {"aaa": "t:0"}, { + counter: { count: 100 }, + createOp: { counterCreate: { count: 100 } } + }), + build_object_state("map:profile@1000", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "nested": { data: { objectId: "counter:nested@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:nested@1000", {"aaa": "t:0"}, { + counter: { count: 5 }, + createOp: { counterCreate: { count: 5 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# counter:score@1000 is referenced by root at key "score" +score = pool["counter:score@1000"] +ASSERT score.parentReferences["root"] == {"score"} + +# map:profile@1000 is referenced by root at key "profile" +profile = pool["map:profile@1000"] +ASSERT profile.parentReferences["root"] == {"profile"} + +# counter:nested@1000 is referenced by map:profile@1000 at key "nested" +nested = pool["counter:nested@1000"] +ASSERT nested.parentReferences["map:profile@1000"] == {"nested"} + +# root has no parent references +ASSERT pool["root"].parentReferences == {} + +# getFullPaths works correctly after rebuild +ASSERT score.getFullPaths() CONTAINS ["score"] +ASSERT nested.getFullPaths() CONTAINS ["profile", "nested"] +``` + +--- + +## RTO5c10a - Post-sync rebuild clears stale parentReferences + +**Test ID**: `objects/unit/RTO5c10a/rebuild-clears-stale-refs-0` + +| Spec | Requirement | +|------|-------------| +| RTO5c10a | For each LiveObject, reset parentReferences to the initial value (empty map) | +| RTO5c10b | Then rebuild from current LiveMap entries | + +Tests that parentReferences from a previous sync are cleared and rebuilt from the new sync data, even when objects are reused across syncs. + +### Setup +```pseudo +pool = ObjectsPool() + +# First sync: root --"score"--> counter:abc@1000 +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "score": { data: { objectId: "counter:abc@1000" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:0"}, { + counter: { count: 10 }, + createOp: { counterCreate: { count: 10 } } + }) +])) +ASSERT pool["counter:abc@1000"].parentReferences["root"] == {"score"} +``` + +### Test Steps +```pseudo +# Second sync: root --"points"--> counter:abc@1000 (key changed from "score" to "points") +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync2:cursor", flags: HAS_OBJECTS +)) +pool.processObjectSync(build_object_sync_message("test", "sync2:", [ + build_object_state("root", {"aaa": "t:1"}, { + map: { + semantics: "LWW", + entries: { + "points": { data: { objectId: "counter:abc@1000" }, timeserial: "t:1" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:abc@1000", {"aaa": "t:1"}, { + counter: { count: 20 }, + createOp: { counterCreate: { count: 20 } } + }) +])) +``` + +### Assertions +```pseudo +counter = pool["counter:abc@1000"] + +# Old "score" reference should be gone, replaced by "points" +ASSERT counter.parentReferences["root"] == {"points"} +ASSERT counter.getFullPaths() CONTAINS ["points"] + +paths = counter.getFullPaths() +ASSERT paths.length == 1 +``` + +--- + +## RTO5c10 - Post-sync unreferenced objects have empty parentReferences + +**Test ID**: `objects/unit/RTO5c10/unreferenced-empty-refs-0` + +**Spec requirement:** Objects that exist in the pool but are not referenced by any LiveMap entry have empty parentReferences after rebuild. + +### Setup +```pseudo +pool = ObjectsPool() +pool.processAttached(ProtocolMessage( + action: ATTACHED, channel: "test", channelSerial: "sync1:cursor", flags: HAS_OBJECTS +)) +``` + +### Test Steps +```pseudo +pool.processObjectSync(build_object_sync_message("test", "sync1:", [ + build_object_state("root", {"aaa": "t:0"}, { + map: { + semantics: "LWW", + entries: { + "name": { data: { string: "Alice" }, timeserial: "t:0" } + } + }, + createOp: { mapCreate: { semantics: "LWW", entries: {} } } + }), + build_object_state("counter:orphan@1000", {"aaa": "t:0"}, { + counter: { count: 42 }, + createOp: { counterCreate: { count: 42 } } + }) +])) +``` + +### Assertions +```pseudo +ASSERT pool.syncState == SYNCED + +# The counter exists in the pool but no LiveMap entry points to it +orphan = pool["counter:orphan@1000"] +ASSERT orphan.parentReferences == {} + +# getFullPaths returns empty list for unreferenced object +ASSERT orphan.getFullPaths().length == 0 +``` diff --git a/uts/objects/unit/path_object.md b/uts/objects/unit/path_object.md index 5a83c8e9c..96d989754 100644 --- a/uts/objects/unit/path_object.md +++ b/uts/objects/unit/path_object.md @@ -167,7 +167,10 @@ ASSERT po.path() == "a\\.b.c" **Test ID**: `objects/unit/RTPO7/value-counter-0` -**Spec requirement:** If resolved value is LiveCounter, returns numeric value. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7c | LiveCounter -> delegates to LiveCounter#value | ### Setup ```pseudo @@ -185,7 +188,10 @@ ASSERT root.get("score").value() == 100 **Test ID**: `objects/unit/RTPO7/value-primitive-0` -**Spec requirement:** If resolved value is a primitive, returns the value directly. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7d | Primitive -> returns value directly | ### Setup ```pseudo @@ -205,7 +211,10 @@ ASSERT root.get("active").value() == true **Test ID**: `objects/unit/RTPO7d/value-livemap-null-0` -**Spec requirement:** If resolved value is a LiveMap, returns null. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7e | LiveMap -> returns null | ### Setup ```pseudo @@ -223,7 +232,10 @@ ASSERT root.get("profile").value() == null **Test ID**: `objects/unit/RTPO7e/value-unresolvable-null-0` -**Spec requirement:** If path resolution fails, returns null. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7f | Resolution failure -> returns null per RTPO3c1 | ### Setup ```pseudo @@ -243,7 +255,8 @@ ASSERT root.get("nonexistent").get("deep").value() == null | Spec | Requirement | |------|-------------| -| RTPO8b | LiveMap or LiveCounter -> Instance wrapping that object | +| RTPO8a | Checks access API preconditions per RTO25 | +| RTPO8c | LiveObject -> Instance wrapping that object | ### Setup ```pseudo @@ -267,7 +280,10 @@ ASSERT map_inst.id() == "map:profile@1000" **Test ID**: `objects/unit/RTPO8c/instance-primitive-null-0` -**Spec requirement:** If resolved value is a primitive, returns null. +| Spec | Requirement | +|------|-------------| +| RTPO8a | Checks access API preconditions per RTO25 | +| RTPO8d | Primitive -> returns null | ### Setup ```pseudo @@ -281,14 +297,15 @@ ASSERT root.get("name").instance() == null --- -## RTPO9 - entries() yields [key, PathObject] pairs +## RTPO9 - entries() returns array of [key, PathObject] pairs **Test ID**: `objects/unit/RTPO9/entries-yields-pairs-0` | Spec | Requirement | |------|-------------| -| RTPO9b | Iterator of [key, PathObject] for LiveMap entries | -| RTPO9c | Only non-tombstoned entries | +| RTPO9a | Checks access API preconditions per RTO25 | +| RTPO9c | Uses LiveMap#keys (RTLM12) to get keys, returns array of [key, PathObject] pairs | +| RTPO9d | Only non-tombstoned entries (tombstoned excluded by LiveMap#keys) | ### Setup ```pseudo @@ -311,11 +328,14 @@ ASSERT entries.length == 7 --- -## RTPO9d - entries() returns empty iterator for non-LiveMap +## RTPO9d - entries() returns empty array for non-LiveMap **Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` -**Spec requirement:** If resolved value is not LiveMap or resolution fails, return empty iterator. +| Spec | Requirement | +|------|-------------| +| RTPO9a | Checks access API preconditions per RTO25 | +| RTPO9d | Not LiveMap or resolution failure -> returns empty array | ### Setup ```pseudo @@ -324,7 +344,7 @@ ASSERT entries.length == 7 ### Test Steps ```pseudo -entries = list(root.get("score").entries()) +entries = root.get("score").entries() ``` ### Assertions @@ -334,11 +354,132 @@ ASSERT entries.length == 0 --- +## RTPO10 - keys() returns array of key strings + +**Test ID**: `objects/unit/RTPO10/keys-returns-array-0` + +| Spec | Requirement | +|------|-------------| +| RTPO10a | Checks access API preconditions per RTO25 | +| RTPO10c | LiveMap -> delegates to LiveMap#keys (RTLM12) | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = root.keys() +``` + +### Assertions +```pseudo +ASSERT keys IS Array +ASSERT keys.length == 7 +ASSERT "name" IN keys +ASSERT "profile" IN keys +ASSERT "score" IN keys +``` + +--- + +## RTPO10d - keys() returns empty array for non-LiveMap + +**Test ID**: `objects/unit/RTPO10d/keys-non-map-empty-0` + +| Spec | Requirement | +|------|-------------| +| RTPO10a | Checks access API preconditions per RTO25 | +| RTPO10d | Not LiveMap or resolution failure -> returns empty array | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +keys = root.get("score").keys() +``` + +### Assertions +```pseudo +ASSERT keys IS Array +ASSERT keys.length == 0 +``` + +--- + +## RTPO11 - values() returns array of PathObjects + +**Test ID**: `objects/unit/RTPO11/values-returns-array-0` + +| Spec | Requirement | +|------|-------------| +| RTPO11a | Checks access API preconditions per RTO25 | +| RTPO11c | LiveMap -> uses LiveMap#keys (RTLM12) and returns array of PathObjects | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +vals = root.values() +``` + +### Assertions +```pseudo +ASSERT vals IS Array +ASSERT vals.length == 7 +// Each element is a PathObject whose path is the key +paths = {} +FOR v IN vals: + paths[v.path()] = true +ASSERT paths["name"] == true +ASSERT paths["profile"] == true +ASSERT paths["score"] == true +``` + +--- + +## RTPO11d - values() returns empty array for non-LiveMap + +**Test ID**: `objects/unit/RTPO11d/values-non-map-empty-0` + +| Spec | Requirement | +|------|-------------| +| RTPO11a | Checks access API preconditions per RTO25 | +| RTPO11d | Not LiveMap or resolution failure -> returns empty array | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +vals = root.get("score").values() +``` + +### Assertions +```pseudo +ASSERT vals IS Array +ASSERT vals.length == 0 +``` + +--- + ## RTPO12 - size() returns non-tombstoned count **Test ID**: `objects/unit/RTPO12/size-count-0` -**Spec requirement:** For LiveMap, returns non-tombstoned entry count. +| Spec | Requirement | +|------|-------------| +| RTPO12a | Checks access API preconditions per RTO25 | +| RTPO12c | LiveMap -> delegates to LiveMap#size (RTLM10) | ### Setup ```pseudo @@ -357,6 +498,11 @@ ASSERT root.get("profile").size() == 3 **Test ID**: `objects/unit/RTPO12c/size-non-map-null-0` +| Spec | Requirement | +|------|-------------| +| RTPO12a | Checks access API preconditions per RTO25 | +| RTPO12d | Not LiveMap or resolution failure -> returns null | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -376,10 +522,11 @@ ASSERT root.get("name").size() == null | Spec | Requirement | |------|-------------| -| RTPO13b1 | Each entry included, tombstoned excluded | -| RTPO13b2 | Nested LiveMap recursively compacted | -| RTPO13b3 | Nested LiveCounter resolved to number | -| RTPO13b4 | Primitives as-is | +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13c1 | Each entry included, tombstoned excluded | +| RTPO13c2 | Nested LiveMap recursively compacted | +| RTPO13c3 | Nested LiveCounter resolved to number | +| RTPO13c4 | Primitives as-is | ### Setup ```pseudo @@ -410,7 +557,10 @@ ASSERT result["profile"]["prefs"]["theme"] == "dark" **Test ID**: `objects/unit/RTPO13b5/compact-cycle-detection-0` -**Spec requirement:** Cyclic references reuse the already-compacted in-memory object. +| Spec | Requirement | +|------|-------------| +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13c5 | Cyclic references reuse already-compacted in-memory object | ### Setup ```pseudo @@ -437,6 +587,11 @@ ASSERT result["prefs"]["back_ref"] IS result **Test ID**: `objects/unit/RTPO13c/compact-counter-0` +| Spec | Requirement | +|------|-------------| +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13d | LiveCounter -> returns numeric value | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -455,8 +610,9 @@ ASSERT root.get("score").compact() == 100 | Spec | Requirement | |------|-------------| -| RTPO14a1 | Binary as base64 strings | -| RTPO14a2 | Cycles as {objectId: ...} | +| RTPO14a | Checks access API preconditions per RTO25 | +| RTPO14b1 | Binary as base64 strings | +| RTPO14b2 | Cycles as {objectId: ...} | ### Setup ```pseudo @@ -567,7 +723,10 @@ ASSERT error.code == 40003 **Test ID**: `objects/unit/RTPO7/value-bytes-0` -**Spec requirement:** If resolved value is bytes, returns the raw binary data. +| Spec | Requirement | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7d | Primitive (Binary) -> returns raw binary data | ### Setup ```pseudo @@ -585,7 +744,10 @@ ASSERT root.get("avatar").value() IS bytes [1, 2, 3] **Test ID**: `objects/unit/RTPO14/compact-json-bytes-0` -**Spec requirement:** Binary values encoded as base64 strings in JSON representation. +| Spec | Requirement | +|------|-------------| +| RTPO14a | Checks access API preconditions per RTO25 | +| RTPO14b1 | Binary values encoded as base64 strings | ### Setup ```pseudo diff --git a/uts/objects/unit/path_object_mutations.md b/uts/objects/unit/path_object_mutations.md index ef33a1a15..43e8f2d59 100644 --- a/uts/objects/unit/path_object_mutations.md +++ b/uts/objects/unit/path_object_mutations.md @@ -21,8 +21,9 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct | Spec | Requirement | |------|-------------| -| RTPO15b | Resolves path, on failure throws RTPO3c2 | -| RTPO15c | LiveMap -> delegates to LiveMap#set | +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO15c | Resolves path, on failure throws RTPO3c2 | +| RTPO15d | LiveMap -> delegates to LiveMap#set (RTLM20) | ### Setup ```pseudo @@ -45,6 +46,11 @@ ASSERT root.get("name").value() == "Bob" **Test ID**: `objects/unit/RTPO15/set-nested-path-0` +| Spec | Requirement | +|------|-------------| +| RTPO15a2 | value accepts same types as LiveMap#set (RTLM20): primitives and LiveCounterValueType/LiveMapValueType | +| RTPO15b | Checks write API preconditions per RTO26 | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -66,7 +72,10 @@ ASSERT root.get("profile").get("email").value() == "bob@example.com" **Test ID**: `objects/unit/RTPO15d/set-non-map-throws-0` -**Spec requirement:** If resolved value is not a LiveMap, throw 92007. +| Spec | Requirement | +|------|-------------| +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO15e | Not LiveMap -> throws 92007 | ### Setup ```pseudo @@ -91,8 +100,9 @@ ASSERT error.code == 92007 | Spec | Requirement | |------|-------------| -| RTPO16b | Resolves path, on failure throws RTPO3c2 | -| RTPO16c | LiveMap -> delegates to LiveMap#remove | +| RTPO16b | Checks write API preconditions per RTO26 | +| RTPO16c | Resolves path, on failure throws RTPO3c2 | +| RTPO16d | LiveMap -> delegates to LiveMap#remove (RTLM21) | ### Setup ```pseudo @@ -115,7 +125,10 @@ ASSERT root.get("name").value() == null **Test ID**: `objects/unit/RTPO16d/remove-non-map-throws-0` -**Spec requirement:** If resolved value is not a LiveMap, throw 92007. +| Spec | Requirement | +|------|-------------| +| RTPO16b | Checks write API preconditions per RTO26 | +| RTPO16e | Not LiveMap -> throws 92007 | ### Setup ```pseudo @@ -140,8 +153,9 @@ ASSERT error.code == 92007 | Spec | Requirement | |------|-------------| -| RTPO17b | Resolves path, on failure throws RTPO3c2 | -| RTPO17c | LiveCounter -> delegates to LiveCounter#increment | +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO17c | Resolves path, on failure throws RTPO3c2 | +| RTPO17d | LiveCounter -> delegates to LiveCounter#increment (RTLC12) | ### Setup ```pseudo @@ -164,7 +178,10 @@ ASSERT root.get("score").value() == 125 **Test ID**: `objects/unit/RTPO17/increment-default-amount-0` -**Spec requirement:** amount defaults to 1. +| Spec | Requirement | +|------|-------------| +| RTPO17a1 | amount defaults to 1 | +| RTPO17b | Checks write API preconditions per RTO26 | ### Setup ```pseudo @@ -187,7 +204,10 @@ ASSERT root.get("score").value() == 101 **Test ID**: `objects/unit/RTPO17d/increment-non-counter-throws-0` -**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. +| Spec | Requirement | +|------|-------------| +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO17e | Not LiveCounter -> throws 92007 | ### Setup ```pseudo @@ -212,8 +232,9 @@ ASSERT error.code == 92007 | Spec | Requirement | |------|-------------| -| RTPO18b | Resolves path, on failure throws RTPO3c2 | -| RTPO18c | LiveCounter -> delegates to LiveCounter#decrement | +| RTPO18b | Checks write API preconditions per RTO26 | +| RTPO18c | Resolves path, on failure throws RTPO3c2 | +| RTPO18d | LiveCounter -> delegates to LiveCounter#decrement (RTLC13) | ### Setup ```pseudo @@ -236,7 +257,10 @@ ASSERT root.get("score").value() == 90 **Test ID**: `objects/unit/RTPO18/decrement-default-amount-0` -**Spec requirement:** amount defaults to 1. +| Spec | Requirement | +|------|-------------| +| RTPO18a1 | amount defaults to 1 | +| RTPO18b | Checks write API preconditions per RTO26 | ### Setup ```pseudo @@ -259,7 +283,10 @@ ASSERT root.get("score").value() == 99 **Test ID**: `objects/unit/RTPO18d/decrement-non-counter-throws-0` -**Spec requirement:** If resolved value is not a LiveCounter, throw 92007. +| Spec | Requirement | +|------|-------------| +| RTPO18b | Checks write API preconditions per RTO26 | +| RTPO18e | Not LiveCounter -> throws 92007 | ### Setup ```pseudo @@ -282,7 +309,10 @@ ASSERT error.code == 92007 **Test ID**: `objects/unit/RTPO3c2/set-unresolvable-throws-0` -**Spec requirement:** For write operations, if path resolution fails, throw 92005. +| Spec | Requirement | +|------|-------------| +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO3c2 | Write operations on unresolvable path throw ErrorInfo with statusCode 400, code 92005 | ### Setup ```pseudo @@ -297,6 +327,7 @@ AWAIT root.get("nonexistent").get("deep").set("key", "value") FAILS WITH error ### Assertions ```pseudo ASSERT error.code == 92005 +ASSERT error.statusCode == 400 ``` --- @@ -305,6 +336,11 @@ ASSERT error.code == 92005 **Test ID**: `objects/unit/RTPO3c2/increment-unresolvable-throws-0` +| Spec | Requirement | +|------|-------------| +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO3c2 | Write operations on unresolvable path throw ErrorInfo with statusCode 400, code 92005 | + ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") @@ -318,4 +354,5 @@ AWAIT root.get("nonexistent").increment(5) FAILS WITH error ### Assertions ```pseudo ASSERT error.code == 92005 +ASSERT error.statusCode == 400 ``` diff --git a/uts/objects/unit/path_object_subscribe.md b/uts/objects/unit/path_object_subscribe.md index 503ac43f2..5f0de3fbc 100644 --- a/uts/objects/unit/path_object_subscribe.md +++ b/uts/objects/unit/path_object_subscribe.md @@ -1,6 +1,6 @@ # PathObject Subscribe Tests -Spec points: `RTPO19`–`RTPO21`, `RTO24` +Spec points: `RTPO19`, `RTO24`, `RTO25` ## Test Type Unit test with mocked WebSocket client @@ -21,9 +21,9 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel` and builder funct | Spec | Requirement | |------|-------------| -| RTPO19c | Returns Subscription object | -| RTPO19d1 | Event.object is a PathObject pointing to change path | -| RTPO19d2 | Event.message is the ObjectMessage | +| RTPO19d | Returns Subscription object | +| RTPO19e1 | Event.object is a PathObject pointing to change path | +| RTPO19e2 | Event.message is the PublicAPI::ObjectMessage | ### Setup ```pseudo @@ -47,15 +47,117 @@ ASSERT events.length == 1 ASSERT events[0].object IS PathObject ASSERT events[0].object.path() == "score" ASSERT events[0].message IS NOT null +ASSERT events[0].message.serial == "99" +ASSERT events[0].message.siteCode == "remote" +ASSERT events[0].message.operation IS NOT null +ASSERT events[0].message.operation.action == "COUNTER_INC" +ASSERT events[0].message.channel == "test" ``` --- -## RTPO19b1b - subscribe() with depth 1 only receives self events +## RTPO19b - subscribe() checks RTO25 access API preconditions on DETACHED channel -**Test ID**: `objects/unit/RTPO19b1b/subscribe-depth-1-self-only-0` +**Test ID**: `objects/unit/RTPO19b/subscribe-precondition-detached-0` -**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener. +| Spec | Requirement | +|------|-------------| +| RTPO19b | Checks the access API preconditions per RTO25 | +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that subscribe() on a DETACHED channel throws 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", + connectionKey: "conn-key-1", + siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: { key: "fake:key", autoConnect: true }) +channel = client.channels.get("test", { + modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] +}) +channel.attach() +AWAIT_STATE channel.state == DETACHED +root_path = channel.object.getRoot() +``` + +### Test Steps +```pseudo +root_path.subscribe((event) => {}) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTPO19c1a - subscribe() with non-positive depth throws 40003 + +**Test ID**: `objects/unit/RTPO19c1a/subscribe-non-positive-depth-throws-0` + +**Spec requirement:** If depth is provided and is not a positive integer, throw 40003. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: 0 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19c1a - subscribe() with negative depth throws 40003 + +**Test ID**: `objects/unit/RTPO19c1a/subscribe-negative-depth-throws-0` + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +root.subscribe((event) => {}, { depth: -1 }) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTPO19c1 - subscribe() with depth 1 only receives self events + +**Test ID**: `objects/unit/RTPO19c1/subscribe-depth-1-self-only-0` + +**Spec requirement:** depth=1 means only changes at the exact subscribed path trigger the listener (RTO24c2b). ### Setup ```pseudo @@ -83,11 +185,11 @@ ASSERT events.length == 1 --- -## RTPO19b1c - subscribe() with depth 2 receives self and children +## RTPO19c1 - subscribe() with depth 2 receives self and children -**Test ID**: `objects/unit/RTPO19b1c/subscribe-depth-2-children-0` +**Test ID**: `objects/unit/RTPO19c1/subscribe-depth-2-children-0` -**Spec requirement:** depth=n means changes up to n-1 levels of children trigger the listener. +**Spec requirement:** depth=2 means changes at the subscribed path and one level of children trigger the listener (RTO24c2c). ### Setup ```pseudo @@ -120,11 +222,11 @@ ASSERT events.length == 2 --- -## RTPO19b1a - subscribe() with no depth receives all descendants +## RTPO19c1 - subscribe() with no depth receives all descendants -**Test ID**: `objects/unit/RTPO19b1a/subscribe-unlimited-depth-0` +**Test ID**: `objects/unit/RTPO19c1/subscribe-unlimited-depth-0` -**Spec requirement:** If depth is undefined, subscription receives events at any depth. +**Spec requirement:** If depth is undefined, subscription receives events at any depth (RTO24c2a). ### Setup ```pseudo @@ -158,55 +260,76 @@ ASSERT events.length >= 3 --- -## RTPO19b1d - subscribe() with non-positive depth throws 40003 +## RTPO19d - subscribe() returns Subscription with unsubscribe() -**Test ID**: `objects/unit/RTPO19b1d/subscribe-non-positive-depth-throws-0` +**Test ID**: `objects/unit/RTPO19d/subscribe-returns-subscription-0` -**Spec requirement:** If depth is provided and is not a positive integer, throw 40003. +**Spec requirement:** RTPO19d: subscribe returns a Subscription (SUB1) object. Calling unsubscribe() deregisters the listener. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +sub = root.get("score").subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -root.subscribe((event) => {}, { depth: 0 }) FAILS WITH error +ASSERT sub IS Subscription +sub.unsubscribe() + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) ``` ### Assertions ```pseudo -ASSERT error.code == 40003 +ASSERT events.length == 0 ``` --- -## RTPO19b1d - subscribe() with negative depth throws 40003 +## RTPO19e1 - subscribe() event provides correct PathObject + +**Test ID**: `objects/unit/RTPO19e1/event-path-object-correct-0` -**Test ID**: `objects/unit/RTPO19b1d/subscribe-negative-depth-throws-0` +**Spec requirement:** RTPO19e1: event.object is a PathObject pointing to the change location. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -root.subscribe((event) => {}, { depth: -1 }) FAILS WITH error +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 7, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT error.code == 40003 +ASSERT events[0].object IS PathObject +ASSERT events[0].object.path() == "score" +ASSERT events[0].object.value() == 107 ``` --- -## RTPO19e - subscribe() follows path not identity +## RTPO19e2 - subscribe() event delivers PublicAPI::ObjectMessage for operations + +**Test ID**: `objects/unit/RTPO19e2/event-message-delivery-0` -**Test ID**: `objects/unit/RTPO19e/subscribe-follows-path-0` +| Spec | Requirement | +|------|-------------| +| RTPO19e2 | event.message is a PublicAPI::ObjectMessage derived from the LiveObjectUpdate.objectMessage per PAOM3 | +| RTO24b2b2 | message populated when objectMessage has an operation field | -**Spec requirement:** If the object at the path changes identity, the subscription continues to deliver events for the new object. +Tests that the event delivered to a subscription listener includes a `message` field containing a `PublicAPI::ObjectMessage` with the correct fields copied from the source ObjectMessage. ### Setup ```pseudo @@ -217,402 +340,459 @@ root.get("score").subscribe((event) => events.append(event)) ### Test Steps ```pseudo -// Replace the counter at "score" with a new counter mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") -])) - -// Increment the NEW counter at "score" -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:new@2000", 10, "100", "remote") + build_counter_inc("counter:score@1000", 42, "serial-1", "site-a") ])) poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -// Should receive event for the new counter, since subscription follows path -found_new = false -FOR event IN events: - IF event.object.path() == "score": - found_new = true -ASSERT found_new == true +ASSERT events[0].message IS NOT null +ASSERT events[0].message.channel == "test" +ASSERT events[0].message.serial == "serial-1" +ASSERT events[0].message.siteCode == "site-a" +ASSERT events[0].message.operation IS NOT null +ASSERT events[0].message.operation.action == "COUNTER_INC" +ASSERT events[0].message.operation.objectId == "counter:score@1000" +ASSERT events[0].message.operation.counterInc.number == 42 ``` --- -## RTPO19f - child events bubble up to parent subscription +## RTPO19e2 - subscribe() event omits message when objectMessage has no operation -**Test ID**: `objects/unit/RTPO19f/child-events-bubble-0` +**Test ID**: `objects/unit/RTPO19e2/event-message-omitted-no-operation-0` -**Spec requirement:** Events at child paths bubble up subject to depth filtering. +**Spec requirement:** RTPO19e2: if the objectMessage's operation field is not populated, message is omitted. + +Tests that events triggered by non-operation updates (e.g. sync-only changes) do not include a message field. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -root.get("profile").subscribe((event) => events.append(event)) +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -mock_ws.send_to_client(build_object_message("test", [ - build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") -])) +// Send a MAP_SET on the root that replaces "score" with a new objectId, +// which triggers a subscription event on root. +// Then send an OBJECT_SYNC that changes counter:score@1000's state +// without an operation field — this triggers an update via replaceData +// which has no objectMessage.operation +mock_ws.send_to_client(ProtocolMessage( + action: OBJECT_SYNC, + channel: "test", + channelSerial: "sync2:", + state: [ + build_object_state("counter:score@1000", {"aaa": "t:1"}, { + counter: { count: 200 }, + createOp: { counterCreate: { count: 200 } } + }) + ] +)) poll_until(events.length >= 1, timeout: 5s) - -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:nested@1000", 3, "100", "remote") -])) -poll_until(events.length >= 2, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length >= 2 +// Events from sync-triggered updates should have no message +FOR event IN events: + ASSERT event.message IS null OR event.message IS undefined ``` --- -## RTO24b3 - depth filtering formula +## RTPO19f - subscribe() follows path not identity -**Test ID**: `objects/unit/RTO24b3/depth-filtering-formula-0` +**Test ID**: `objects/unit/RTPO19f/subscribe-follows-path-0` -**Spec requirement:** Event dispatched if `eventPath.length - subscriptionPath.length + 1 <= depth`. +**Spec requirement:** RTPO19f: subscription is registered by path, so if the object at the path changes identity, the subscription continues to deliver events for the new object. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -// Subscribe at "profile" with depth 2: -// self (profile) → segmentDiff=0, 0+1=1 ≤ 2 ✓ -// child (profile.email) → segmentDiff=1, 1+1=2 ≤ 2 ✓ -// grandchild (profile.prefs.theme) → segmentDiff=2, 2+1=3 > 2 ✗ -root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) +root.get("score").subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -// Self event (profile map update) +// Replace the counter at "score" with a new counter mock_ws.send_to_client(build_object_message("test", [ - build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") ])) -poll_until(events.length >= 1, timeout: 5s) -// Child event (nested counter) -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:nested@1000", 3, "100", "remote") -])) -poll_until(events.length >= 2, timeout: 5s) - -// Grandchild event (prefs.theme) — should NOT be received +// Increment the NEW counter at "score" mock_ws.send_to_client(build_object_message("test", [ - build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") + build_counter_inc("counter:new@2000", 10, "100", "remote") ])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length == 2 +// Should receive event for the new counter, since subscription follows path +found_new = false +FOR event IN events: + IF event.object.path() == "score": + found_new = true +ASSERT found_new == true ``` --- -## RTO24b5 - listener exception does not affect other listeners +## RTPO19g - subscribe() has no side effects -**Test ID**: `objects/unit/RTO24b5/listener-exception-caught-0` +**Test ID**: `objects/unit/RTPO19g/subscribe-no-side-effects-0` -**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions. +**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -events = [] -root.subscribe((event) => { THROW Error("boom") }) -root.subscribe((event) => events.append(event)) +state_before = channel.state ``` ### Test Steps ```pseudo -mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "name", { string: "Bob" }, "99", "remote") -])) -poll_until(events.length >= 1, timeout: 5s) +root.get("score").subscribe((event) => {}) ``` ### Assertions ```pseudo -ASSERT events.length == 1 +ASSERT channel.state == state_before ``` --- -## RTPO20 - unsubscribe() deregisters listener +## RTPO19 - subscribe() on primitive path receives change events -**Test ID**: `objects/unit/RTPO20/unsubscribe-deregisters-0` +**Test ID**: `objects/unit/RTPO19/subscribe-primitive-path-0` + +**Spec requirement:** A subscription on a path pointing to a primitive (e.g., root.get("name")) fires when the map entry at that key changes. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -sub = root.get("score").subscribe((event) => events.append(event)) -sub.unsubscribe() +root.get("name").subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 7, "99", "remote") + build_map_set("root", "name", { string: "Bob" }, "99", "remote") ])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length == 0 +ASSERT events.length == 1 +ASSERT events[0].object.path() == "name" ``` --- -## RTPO19g - subscribe() has no side effects +## RTPO19 - MAP_CLEAR triggers subscription events on child paths -**Test ID**: `objects/unit/RTPO19g/subscribe-no-side-effects-0` +**Test ID**: `objects/unit/RTPO19/map-clear-triggers-child-events-0` -**Spec requirement:** Must not have side effects on RealtimeObject, channel, or their status. +**Spec requirement:** When MAP_CLEAR is applied, subscriptions on affected child paths receive events. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -state_before = channel.state +events = [] +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -root.get("score").subscribe((event) => {}) +mock_ws.send_to_client(build_object_message("test", [ + build_map_clear("root", "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT channel.state == state_before +ASSERT events.length >= 1 ``` --- -## RTPO19 - MAP_CLEAR triggers subscription events on child paths +## RTPO19 - child events bubble up to parent subscription -**Test ID**: `objects/unit/RTPO19/map-clear-triggers-child-events-0` +**Test ID**: `objects/unit/RTPO19/child-events-bubble-0` -**Spec requirement:** When MAP_CLEAR is applied, subscriptions on affected child paths receive events. +**Spec requirement:** Events at child paths bubble up subject to depth filtering. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -root.subscribe((event) => events.append(event)) +root.get("profile").subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo mock_ws.send_to_client(build_object_message("test", [ - build_map_clear("root", "99", "remote") + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") ])) poll_until(events.length >= 1, timeout: 5s) + +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT events.length >= 1 +ASSERT events.length >= 2 ``` --- -## RTPO19 - subscribe() on primitive path receives change events +## RTO24c1 - depth filtering formula -**Test ID**: `objects/unit/RTPO19/subscribe-primitive-path-0` +**Test ID**: `objects/unit/RTO24c1/depth-filtering-formula-0` -**Spec requirement:** A subscription on a path pointing to a primitive (e.g., root.get("name")) fires when the map entry at that key changes. +| Spec | Requirement | +|------|-------------| +| RTO24c1 | subPath is a prefix of eventPath AND (depth null OR eventPath.length - subPath.length + 1 <= depth) | ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") events = [] -root.get("name").subscribe((event) => events.append(event)) +// Subscribe at "profile" with depth 2: +// self (profile) -> eventPath=["profile"], subPath=["profile"], 1 - 1 + 1 = 1 <= 2 yes +// child (profile.email) -> eventPath=["profile","email"], subPath=["profile"], 2 - 1 + 1 = 2 <= 2 yes +// grandchild (profile.prefs.theme) -> eventPath=["profile","prefs","theme"], subPath=["profile"], 3 - 1 + 1 = 3 > 2 no +root.get("profile").subscribe((event) => events.append(event), { depth: 2 }) ``` ### Test Steps ```pseudo +// Self event (profile map update) mock_ws.send_to_client(build_object_message("test", [ - build_map_set("root", "name", { string: "Bob" }, "99", "remote") + build_map_set("map:profile@1000", "email", { string: "bob@example.com" }, "99", "remote") ])) poll_until(events.length >= 1, timeout: 5s) + +// Child event (nested counter) +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 3, "100", "remote") +])) +poll_until(events.length >= 2, timeout: 5s) + +// Grandchild event (prefs.theme) — should NOT be received +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("map:prefs@1000", "theme", { string: "light" }, "101", "remote") +])) ``` ### Assertions ```pseudo -ASSERT events.length == 1 -ASSERT events[0].object.path() == "name" +ASSERT events.length == 2 ``` --- -## RTPO19d - subscribe() event provides correct PathObject +## RTO24c1 - prefix mismatch does not trigger subscription -**Test ID**: `objects/unit/RTPO19d/event-path-object-correct-0` +**Test ID**: `objects/unit/RTO24c1/prefix-mismatch-0` -**Spec requirement:** RTPO19d1: event.object is a PathObject pointing to the change location. +| Spec | Requirement | +|------|-------------| +| RTO24c1 | subPath must be a prefix of eventPath | +| RTO24c2d | ["admins"] and ["userPosts"] not covered by subscription at ["users"] | + +Tests that a subscription at one path does not receive events for a sibling path that is not a prefix match. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -events = [] -root.subscribe((event) => events.append(event)) +profile_events = [] +root.get("profile").subscribe((event) => profile_events.append(event)) ``` ### Test Steps ```pseudo +// Change at "score" — "profile" is not a prefix of "score" mock_ws.send_to_client(build_object_message("test", [ build_counter_inc("counter:score@1000", 7, "99", "remote") ])) -poll_until(events.length >= 1, timeout: 5s) + +// Change at "name" — "profile" is not a prefix of "name" +mock_ws.send_to_client(build_object_message("test", [ + build_map_set("root", "name", { string: "Bob" }, "100", "remote") +])) ``` ### Assertions ```pseudo -ASSERT events[0].object IS PathObject -ASSERT events[0].object.path() == "score" -ASSERT events[0].object.value() == 107 +ASSERT profile_events.length == 0 ``` --- -## RTPO21 - subscribeIterator() yields events +## RTO24b2a - candidate path construction includes map update keys -**Test ID**: `objects/unit/RTPO21/subscribe-iterator-yields-0` +**Test ID**: `objects/unit/RTO24b2a/candidate-paths-map-keys-0` | Spec | Requirement | |------|-------------| -| RTPO21b | Returns async iterable of PathObjectSubscriptionEvent | -| RTPO21d | Each iteration yields next event | +| RTO24b2a1 | First candidate is pathToThis itself | +| RTO24b2a2 | For LiveMapUpdate, append pathToThis extended by each update key | + +Tests that when a MAP_SET updates a key on a map, subscriptions on the child path (pathToThis + key) are notified, not just subscriptions on the map itself. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -iter = root.get("score").subscribeIterator() +score_events = [] +root_events = [] +// Subscribe at the child path "score" (pathToThis=[""] + key "score" = ["score"]) +root.get("score").subscribe((event) => score_events.append(event)) +// Subscribe at root path (pathToThis=[""]) +root.subscribe((event) => root_events.append(event)) ``` ### Test Steps ```pseudo +// MAP_SET on root with key "score" — generates candidates: +// 1. pathToThis = [] (root itself) +// 2. [] + "score" = ["score"] (from the map update key) +// Both subscriptions should fire mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 7, "99", "remote") + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") ])) - -event = AWAIT iter.next() +poll_until(score_events.length >= 1, timeout: 5s) +poll_until(root_events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT event.object IS PathObject -ASSERT event.object.path() == "score" +ASSERT score_events.length == 1 +ASSERT score_events[0].object.path() == "score" +ASSERT root_events.length == 1 ``` --- -## RTPO21 - subscribeIterator() with depth option +## RTO24b2c - listener exception does not affect other listeners -**Test ID**: `objects/unit/RTPO21/subscribe-iterator-depth-0` +**Test ID**: `objects/unit/RTO24b2c/listener-exception-caught-0` -**Spec requirement:** subscribeIterator accepts same options as subscribe, including depth. +**Spec requirement:** If a listener throws, the error is caught and logged without affecting other subscriptions or other pathToThis iterations. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -iter = root.subscribeIterator({ depth: 1 }) +events = [] +root.subscribe((event) => { THROW Error("boom") }) +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo -// Self event (depth 1 allows) mock_ws.send_to_client(build_object_message("test", [ build_map_set("root", "name", { string: "Bob" }, "99", "remote") ])) -event = AWAIT iter.next() - -// Child event (depth 1 rejects — counter at depth 2) -mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 7, "100", "remote") -])) +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT event.object.path() == "" +ASSERT events.length == 1 ``` --- -## RTPO21 - subscribeIterator() break cleanup +## RTO24b1 - dispatch via getFullPaths for multi-path objects -**Test ID**: `objects/unit/RTPO21/subscribe-iterator-break-cleanup-0` +**Test ID**: `objects/unit/RTO24b1/multi-path-dispatch-0` + +| Spec | Requirement | +|------|-------------| +| RTO24b1 | Let pathsToThis be the set of paths returned by getFullPaths on the LiveObject | +| RTO24b2 | For each pathToThis, construct candidates and dispatch | -**Spec requirement:** Breaking out of the iterator loop cleans up the underlying subscription. +Tests that when a LiveObject is reachable via multiple paths, subscriptions on all those paths receive events. We create this by adding a second reference to the same counter. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -received = [] -``` - -### Test Steps -```pseudo -iter = root.get("score").subscribeIterator() +events_score = [] +events_alias = [] +// "score" already points to counter:score@1000. +// Add a second reference "alias" -> counter:score@1000 so it has two paths. mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 1, "99", "remote") + build_map_set("root", "alias", { objectId: "counter:score@1000" }, "98", "remote") ])) -event = AWAIT iter.next() -received.append(event) - -// Break the iterator (cleanup) -iter.return() +root.get("score").subscribe((event) => events_score.append(event)) +root.get("alias").subscribe((event) => events_alias.append(event)) +``` -// Further events should not be received +### Test Steps +```pseudo +// Increment counter:score@1000 — getFullPaths returns ["score"] and ["alias"] mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 1, "100", "remote") + build_counter_inc("counter:score@1000", 5, "99", "remote") ])) +poll_until(events_score.length >= 1, timeout: 5s) +poll_until(events_alias.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT received.length == 1 +ASSERT events_score.length == 1 +ASSERT events_score[0].object.path() == "score" +ASSERT events_alias.length == 1 +ASSERT events_alias[0].object.path() == "alias" ``` --- -## RTPO21 - subscribeIterator() multiple concurrent iterators +## RTO24b2b - subscription fires exactly once per dispatch -**Test ID**: `objects/unit/RTPO21/subscribe-iterator-concurrent-0` +**Test ID**: `objects/unit/RTO24b2b/fires-once-per-dispatch-0` + +| Spec | Requirement | +|------|-------------| +| RTO24b2b | Find the first eventPath in candidatePaths that the subscription covers; call the listener exactly once | -**Spec requirement:** Multiple iterators can coexist independently. +Tests that when a MAP_SET generates multiple candidate paths that a subscription covers, the listener is called exactly once with the first (most preferred) candidate. ### Setup ```pseudo { client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") -iter1 = root.get("score").subscribeIterator() -iter2 = root.get("score").subscribeIterator() +events = [] +// Subscribe at root (unlimited depth) — covers both [] and ["score"] +root.subscribe((event) => events.append(event)) ``` ### Test Steps ```pseudo +// MAP_SET on root with key "score" — candidates are [] and ["score"] +// Root subscription covers both, but should fire exactly once with +// the first candidate (pathToThis = []) mock_ws.send_to_client(build_object_message("test", [ - build_counter_inc("counter:score@1000", 5, "99", "remote") + build_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") ])) - -event1 = AWAIT iter1.next() -event2 = AWAIT iter2.next() +poll_until(events.length >= 1, timeout: 5s) ``` ### Assertions ```pseudo -ASSERT event1.object.path() == "score" -ASSERT event2.object.path() == "score" +// Exactly one event per dispatch, even though multiple candidates match +ASSERT events.length == 1 ``` diff --git a/uts/objects/unit/public_object_message.md b/uts/objects/unit/public_object_message.md new file mode 100644 index 000000000..24373f1b1 --- /dev/null +++ b/uts/objects/unit/public_object_message.md @@ -0,0 +1,555 @@ +# PublicAPI::ObjectMessage and PublicAPI::ObjectOperation Tests + +Spec points: `PAOM1`, `PAOM2`, `PAOM3`, `PAOOP1`, `PAOOP2`, `PAOOP3` + +## Test Type +Unit test — pure data structure construction, no mocks required. + +## Purpose + +Tests the construction of `PublicAPI::ObjectMessage` from an internal `ObjectMessage`, and the construction of `PublicAPI::ObjectOperation` from an internal `ObjectOperation`. These are user-facing types exposed to subscription listeners so that user code can inspect the metadata of the message that triggered an object change. + +Tests verify that all fields are correctly copied, that `channel` comes from the channel object (not from the ObjectMessage), that the `operation` is derived via PAOOP3, and that the `mapCreate`/`counterCreate` resolution logic handles direct, derived-from-WithObjectId, and absent cases correctly. + +--- + +## PAOM3 - Construction copies all fields from source ObjectMessage + +**Test ID**: `objects/unit/PAOM3/construction-all-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOM3b | Set channel attribute to channel.name | +| PAOM3c | Copy id, clientId, connectionId, timestamp, serial, serialTimestamp, siteCode, extras from source | +| PAOM3d | Set operation to PublicAPI::ObjectOperation derived per PAOOP3 | + +Tests that constructing a PublicAPI::ObjectMessage from a source ObjectMessage with all fields populated correctly copies every attribute and derives the operation. + +### Setup +```pseudo +source = ObjectMessage( + id: "msg-id-1", + clientId: "client-1", + connectionId: "conn-1", + timestamp: 1700000000000, + serial: "01", + serialTimestamp: 1700000001000, + siteCode: "site1", + extras: { "key": "value" }, + operation: { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "name", value: { string: "Alice" } } + } +) + +channel = { name: "test-channel" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.id == "msg-id-1" +ASSERT public_msg.clientId == "client-1" +ASSERT public_msg.connectionId == "conn-1" +ASSERT public_msg.timestamp == 1700000000000 +ASSERT public_msg.channel == "test-channel" +ASSERT public_msg.serial == "01" +ASSERT public_msg.serialTimestamp == 1700000001000 +ASSERT public_msg.siteCode == "site1" +ASSERT public_msg.extras == { "key": "value" } +ASSERT public_msg.operation IS NOT null +ASSERT public_msg.operation.action == "MAP_SET" +ASSERT public_msg.operation.objectId == "map:abc@1000" +ASSERT public_msg.operation.mapSet.key == "name" +``` + +--- + +## PAOM3 - Construction with optional fields missing + +**Test ID**: `objects/unit/PAOM3/construction-optional-fields-missing-0` + +| Spec | Requirement | +|------|-------------| +| PAOM2a | id is optional | +| PAOM2b | clientId is optional | +| PAOM2c | connectionId is optional | +| PAOM2d | timestamp is optional | +| PAOM2g | serial is optional | +| PAOM2h | serialTimestamp is optional | +| PAOM2i | siteCode is optional | +| PAOM2j | extras is optional | +| PAOM3c | Copy fields from source; absent fields remain null/undefined | + +Tests that constructing a PublicAPI::ObjectMessage from a source ObjectMessage with only required fields works correctly, and optional fields are null/undefined. + +### Setup +```pseudo +source = ObjectMessage( + operation: { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: { number: 5 } + } +) + +channel = { name: "my-channel" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.id == null +ASSERT public_msg.clientId == null +ASSERT public_msg.connectionId == null +ASSERT public_msg.timestamp == null +ASSERT public_msg.channel == "my-channel" +ASSERT public_msg.serial == null +ASSERT public_msg.serialTimestamp == null +ASSERT public_msg.siteCode == null +ASSERT public_msg.extras == null +ASSERT public_msg.operation IS NOT null +ASSERT public_msg.operation.action == "COUNTER_INC" +``` + +--- + +## PAOM3b - Channel is set from channel.name, not from ObjectMessage + +**Test ID**: `objects/unit/PAOM3/channel-from-channel-name-0` + +**Spec requirement:** The `channel` attribute is set to `channel.name`, not derived from any field on the ObjectMessage itself. + +Tests that the channel field on the PublicAPI::ObjectMessage comes from the channel object's name property. + +### Setup +```pseudo +source = ObjectMessage( + operation: { + action: "OBJECT_DELETE", + objectId: "counter:abc@1000" + } +) + +channel = { name: "different-channel-name" } +``` + +### Test Steps +```pseudo +public_msg = PublicObjectMessage.fromObjectMessage(source, channel) +``` + +### Assertions +```pseudo +ASSERT public_msg.channel == "different-channel-name" +``` + +--- + +## PAOOP3a - MAP_SET operation copies mapSet, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-set-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy action, objectId, mapSet, mapRemove, counterInc, objectDelete, mapClear directly | +| PAOOP2d | mapSet is the mapSet of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_SET source copies action, objectId, and mapSet, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "color", value: { string: "blue" } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_SET" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapSet.key == "color" +ASSERT public_op.mapSet.value.string == "blue" +ASSERT public_op.mapCreate == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - MAP_REMOVE operation copies mapRemove, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-remove-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy mapRemove directly from source | +| PAOOP2e | mapRemove is the mapRemove of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_REMOVE source copies action, objectId, and mapRemove, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_REMOVE", + objectId: "map:abc@1000", + mapRemove: { key: "old-key" } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_REMOVE" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapRemove.key == "old-key" +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - COUNTER_INC operation copies counterInc, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/counter-inc-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy counterInc directly from source | +| PAOOP2g | counterInc is the counterInc of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a COUNTER_INC source copies action, objectId, and counterInc, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "COUNTER_INC", + objectId: "counter:abc@1000", + counterInc: { number: 42 } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_INC" +ASSERT public_op.objectId == "counter:abc@1000" +ASSERT public_op.counterInc.number == 42 +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - OBJECT_DELETE operation copies objectDelete, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/object-delete-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy objectDelete directly from source | +| PAOOP2h | objectDelete is the objectDelete of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from an OBJECT_DELETE source copies action, objectId, and objectDelete, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "OBJECT_DELETE", + objectId: "counter:abc@1000", + objectDelete: {} +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "OBJECT_DELETE" +ASSERT public_op.objectId == "counter:abc@1000" +ASSERT public_op.objectDelete IS NOT null +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.mapClear == null +``` + +--- + +## PAOOP3a - MAP_CLEAR operation copies mapClear, omits unrelated fields + +**Test ID**: `objects/unit/PAOOP3/map-clear-copies-fields-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy mapClear directly from source | +| PAOOP2i | mapClear is the mapClear of the source ObjectOperation | + +Tests that constructing a PublicAPI::ObjectOperation from a MAP_CLEAR source copies action, objectId, and mapClear, and omits all other operation-specific fields. + +### Setup +```pseudo +source_operation = { + action: "MAP_CLEAR", + objectId: "map:abc@1000", + mapClear: {} +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CLEAR" +ASSERT public_op.objectId == "map:abc@1000" +ASSERT public_op.mapClear IS NOT null +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterCreate == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +``` + +--- + +## PAOOP3b1 - MAP_CREATE with mapCreate directly present + +**Test ID**: `objects/unit/PAOOP3/map-create-direct-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b1 | If mapCreate is present on the source, set mapCreate to that value | + +Tests that when the source ObjectOperation has a `mapCreate` field, the PublicAPI::ObjectOperation uses it directly. + +### Setup +```pseudo +source_operation = { + action: "MAP_CREATE", + objectId: "map:new@2000", + mapCreate: { semantics: "LWW", entries: { "key1": { data: { string: "val1" } } } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CREATE" +ASSERT public_op.objectId == "map:new@2000" +ASSERT public_op.mapCreate IS NOT null +ASSERT public_op.mapCreate.semantics == "LWW" +ASSERT public_op.mapCreate.entries["key1"].data.string == "val1" +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3b2 - MAP_CREATE resolved from mapCreateWithObjectId + +**Test ID**: `objects/unit/PAOOP3/map-create-from-with-object-id-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b2 | If mapCreateWithObjectId is present on the source, set mapCreate to the MapCreate from which it was derived | + +Tests that when the source ObjectOperation has `mapCreateWithObjectId` but not `mapCreate`, the PublicAPI::ObjectOperation resolves `mapCreate` to the derived MapCreate. + +### Setup +```pseudo +derived_map_create = { semantics: "LWW", entries: { "x": { data: { number: 10 } } } } + +source_operation = { + action: "MAP_CREATE", + objectId: "map:derived@3000", + mapCreateWithObjectId: { + objectId: "map:derived@3000", + semantics: "LWW", + entries: { "x": { data: { number: 10 } } }, + _derivedFrom: derived_map_create + } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "MAP_CREATE" +ASSERT public_op.objectId == "map:derived@3000" +ASSERT public_op.mapCreate IS NOT null +ASSERT public_op.mapCreate.semantics == "LWW" +ASSERT public_op.mapCreate.entries["x"].data.number == 10 +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3c2 - COUNTER_CREATE resolved from counterCreateWithObjectId + +**Test ID**: `objects/unit/PAOOP3/counter-create-from-with-object-id-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3c2 | If counterCreateWithObjectId is present on the source, set counterCreate to the CounterCreate from which it was derived | + +Tests that when the source ObjectOperation has `counterCreateWithObjectId` but not `counterCreate`, the PublicAPI::ObjectOperation resolves `counterCreate` to the derived CounterCreate. + +### Setup +```pseudo +derived_counter_create = { count: 100 } + +source_operation = { + action: "COUNTER_CREATE", + objectId: "counter:derived@3000", + counterCreateWithObjectId: { + objectId: "counter:derived@3000", + count: 100, + _derivedFrom: derived_counter_create + } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_CREATE" +ASSERT public_op.objectId == "counter:derived@3000" +ASSERT public_op.counterCreate IS NOT null +ASSERT public_op.counterCreate.count == 100 +ASSERT public_op.mapCreate == null +``` + +--- + +## PAOOP3b3, PAOOP3c3 - Create payloads omitted when neither variant is present + +**Test ID**: `objects/unit/PAOOP3/create-payloads-omitted-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3b3 | If neither mapCreate nor mapCreateWithObjectId is present, omit mapCreate | +| PAOOP3c3 | If neither counterCreate nor counterCreateWithObjectId is present, omit counterCreate | + +Tests that when the source ObjectOperation has no create payloads (neither direct nor WithObjectId variants), both `mapCreate` and `counterCreate` are omitted on the resulting PublicAPI::ObjectOperation. + +### Setup +```pseudo +source_operation = { + action: "MAP_SET", + objectId: "map:abc@1000", + mapSet: { key: "k", value: { string: "v" } } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.mapCreate == null +ASSERT public_op.counterCreate == null +``` + +--- + +## PAOOP3 - Only the relevant operation field is present per action type + +**Test ID**: `objects/unit/PAOOP3/only-relevant-field-per-action-0` + +| Spec | Requirement | +|------|-------------| +| PAOOP3a | Copy only the fields that exist on the source; unrelated fields are omitted | +| PAOOP2c | mapCreate is optional | +| PAOOP2d | mapSet is optional | +| PAOOP2e | mapRemove is optional | +| PAOOP2f | counterCreate is optional | +| PAOOP2g | counterInc is optional | +| PAOOP2h | objectDelete is optional | +| PAOOP2i | mapClear is optional | + +Tests that for a COUNTER_CREATE operation with `counterCreate` directly present, only `counterCreate` is set and all other operation-specific fields are null. + +### Setup +```pseudo +source_operation = { + action: "COUNTER_CREATE", + objectId: "counter:new@2000", + counterCreate: { count: 50 } +} +``` + +### Test Steps +```pseudo +public_op = PublicObjectOperation.fromObjectOperation(source_operation) +``` + +### Assertions +```pseudo +ASSERT public_op.action == "COUNTER_CREATE" +ASSERT public_op.objectId == "counter:new@2000" +ASSERT public_op.counterCreate IS NOT null +ASSERT public_op.counterCreate.count == 50 +ASSERT public_op.mapCreate == null +ASSERT public_op.mapSet == null +ASSERT public_op.mapRemove == null +ASSERT public_op.counterInc == null +ASSERT public_op.objectDelete == null +ASSERT public_op.mapClear == null +``` diff --git a/uts/objects/unit/realtime_object.md b/uts/objects/unit/realtime_object.md index fd833be65..4a0fc116e 100644 --- a/uts/objects/unit/realtime_object.md +++ b/uts/objects/unit/realtime_object.md @@ -1,6 +1,6 @@ # RealtimeObject Tests -Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO24` +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO26` ## Test Type Unit test with mocked WebSocket client @@ -21,7 +21,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_ch | Spec | Requirement | |------|-------------| -| RTO23d | Returns PathObject wrapping root LiveMap with empty path | +| RTO23d | Returns PathObject with path set to empty list and root set to root LiveMap | ### Setup ```pseudo @@ -31,7 +31,7 @@ See `helpers/standard_test_pool.md` for `setup_synced_channel`, `setup_synced_ch ### Assertions ```pseudo ASSERT root IS PathObject -ASSERT root.path() == "" +ASSERT root.path == [] ``` --- @@ -75,11 +75,15 @@ ASSERT error.code == 40024 --- -## RTO23b - get() throws on DETACHED or FAILED channel +## RTO23b - get() throws on DETACHED channel **Test ID**: `objects/unit/RTO23b/get-throws-detached-0` -**Spec requirement:** If channel is DETACHED or FAILED, throw 90001. +| Spec | Requirement | +|------|-------------| +| RTO23b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that get() on a DETACHED channel throws 90001 per the RTO25 access API preconditions. ### Setup ```pseudo @@ -88,7 +92,19 @@ mock_ws = MockWebSocket( ProtocolMessage(action: CONNECTED, connectionDetails: { connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" }) - ) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } ) install_mock(mock_ws) client = Realtime(options: { key: "fake:key" }) @@ -97,12 +113,18 @@ channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) ### Test Steps ```pseudo +// Attach and sync first, then detach +AWAIT channel.object.get() +AWAIT channel.detach() +AWAIT_STATE channel.state == DETACHED + AWAIT channel.object.get() FAILS WITH error ``` ### Assertions ```pseudo ASSERT error.code == 90001 +ASSERT error.statusCode == 400 ``` --- @@ -153,6 +175,7 @@ root = AWAIT get_future ### Assertions ```pseudo ASSERT root IS PathObject +ASSERT root.path == [] ``` --- @@ -563,6 +586,412 @@ ASSERT error.code == 40024 --- +## RTO25a - Access API precondition requires OBJECT_SUBSCRIBE mode + +**Test ID**: `objects/unit/RTO25a/access-requires-subscribe-mode-0` + +| Spec | Requirement | +|------|-------------| +| RTO25a | Require OBJECT_SUBSCRIBE channel mode per RTO2 | + +Tests that a read operation (e.g. PathObject value()) without OBJECT_SUBSCRIBE mode throws error 40024. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, + modes: ["OBJECT_PUBLISH"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_PUBLISH"] }) +``` + +### Test Steps +```pseudo +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO25b - Access API precondition throws on DETACHED channel + +**Test ID**: `objects/unit/RTO25b/access-throws-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that calling get() on a DETACHED channel throws 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: msg.channel + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +// Attach, sync, then detach to get channel into DETACHED state +AWAIT channel.object.get() +AWAIT channel.detach() +AWAIT_STATE channel.state == DETACHED + +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO25b - Access API precondition throws on FAILED channel + +**Test ID**: `objects/unit/RTO25b/access-throws-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO25b | If channel is DETACHED or FAILED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that calling get() on a FAILED channel throws 90001. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site" + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, channel: msg.channel, + error: { code: 90000, statusCode: 400, message: "Channel error" } + )) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +``` + +### Test Steps +```pseudo +// Trigger attach which will fail, putting channel into FAILED state +channel.attach() +AWAIT_STATE channel.state == FAILED + +AWAIT channel.object.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26a - Write API precondition requires OBJECT_PUBLISH mode + +**Test ID**: `objects/unit/RTO26a/write-requires-publish-mode-0` + +| Spec | Requirement | +|------|-------------| +| RTO26a | Require OBJECT_PUBLISH channel mode per RTO2 | + +Tests that a write operation without OBJECT_PUBLISH mode throws error 40024. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS, + modes: ["OBJECT_SUBSCRIBE"] + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key" }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40024 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26b - Write API precondition throws on DETACHED channel + +**Test ID**: `objects/unit/RTO26b/write-throws-detached-0` + +| Spec | Requirement | +|------|-------------| +| RTO26b | If channel is DETACHED, FAILED, or SUSPENDED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that a write operation on a DETACHED channel throws 90001. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +// Detach the channel after sync +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel detached" } +)) +AWAIT_STATE channel.state == DETACHED + +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26b - Write API precondition throws on FAILED channel + +**Test ID**: `objects/unit/RTO26b/write-throws-failed-0` + +| Spec | Requirement | +|------|-------------| +| RTO26b | If channel is DETACHED, FAILED, or SUSPENDED, throw ErrorInfo with statusCode 400 and code 90001 | + +Tests that a write operation on a FAILED channel throws 90001. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +// Force channel to FAILED state +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, channel: "test", + error: { code: 90000, statusCode: 400, message: "Channel error" } +)) +AWAIT_STATE channel.state == FAILED + +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 90001 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO26c - Write API precondition throws when echoMessages is false + +**Test ID**: `objects/unit/RTO26c/write-throws-echo-disabled-0` + +| Spec | Requirement | +|------|-------------| +| RTO26c | If echoMessages is false, throw ErrorInfo with statusCode 400 and code 40000 | + +Tests that a write operation with echoMessages disabled throws 40000. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionDetails: { + connectionId: "conn-1", connectionKey: "key-1", siteCode: "test-site", + objectsGCGracePeriod: 86400000 + }) + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, channel: msg.channel, channelSerial: "sync1:", + flags: HAS_OBJECTS + )) + mock_ws.send_to_client(build_object_sync_message(msg.channel, "sync1:", STANDARD_POOL_OBJECTS)) + } +) +install_mock(mock_ws) +client = Realtime(options: { key: "fake:key", echoMessages: false }) +channel = client.channels.get("test", { modes: ["OBJECT_SUBSCRIBE", "OBJECT_PUBLISH"] }) +root = AWAIT channel.object.get() +``` + +### Test Steps +```pseudo +AWAIT root.set("name", "Bob") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40000 +ASSERT error.statusCode == 400 +``` + +--- + +## RTO24a - RealtimeObject maintains a single PathObjectSubscriptionRegister + +**Test ID**: `objects/unit/RTO24a/single-register-instance-0` + +**Spec requirement:** The RealtimeObject instance maintains a single PathObjectSubscriptionRegister that manages all path-based subscriptions for the channel. + +Tests that subscriptions registered via different PathObjects on the same channel share a single register, so updates are dispatched to all matching subscriptions regardless of which PathObject was used to subscribe. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +events_root = [] +events_score = [] + +// Subscribe via root PathObject at path [] +root.subscribe((event) => events_root.append(event)) + +// Subscribe via a deeper PathObject at path ["score"] +score_path = root.get("score") +score_path.subscribe((event) => events_score.append(event)) +``` + +### Test Steps +```pseudo +// Trigger an update on the score counter +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "s:1", "aaa") +])) + +poll_until(events_score.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// Both subscriptions are managed by the same register and both fire +ASSERT events_root.length >= 1 +ASSERT events_score.length >= 1 +``` + +--- + +## RTO24c1 - Subscription coverage: prefix match with depth constraint + +**Test ID**: `objects/unit/RTO24c1/coverage-prefix-depth-0` + +| Spec | Requirement | +|------|-------------| +| RTO24c1 | Subscription covers eventPath if subPath is prefix and depth constraint satisfied | + +Tests that a subscription with a depth constraint only receives events within the specified depth. + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") + +shallow_events = [] +deep_events = [] + +// Subscribe at root with depth 1 — covers root and immediate children only +root.subscribe({ depth: 1 }, (event) => shallow_events.append(event)) + +// Subscribe at root with no depth limit — covers everything +root.subscribe((event) => deep_events.append(event)) +``` + +### Test Steps +```pseudo +// Update a direct child of root (path ["score"]) — depth 1 from root +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:score@1000", 5, "s:1", "aaa") +])) +poll_until(deep_events.length >= 1, timeout: 5s) + +// Update a nested object (path ["profile", "nested_counter"]) — depth 2 from root +mock_ws.send_to_client(build_object_message("test", [ + build_counter_inc("counter:nested@1000", 1, "s:2", "aaa") +])) +poll_until(deep_events.length >= 2, timeout: 5s) +``` + +### Assertions +```pseudo +// Shallow subscription (depth 1) only sees the direct child update +ASSERT shallow_events.length == 1 + +// Deep subscription (no depth limit) sees both updates +ASSERT deep_events.length >= 2 +``` + +--- + ## RTO10 - GC removes tombstoned objects past grace period **Test ID**: `objects/unit/RTO10/gc-tombstoned-objects-0` @@ -786,6 +1215,7 @@ root = AWAIT channel.object.get() ### Assertions ```pseudo ASSERT root IS PathObject +ASSERT root.path == [] ASSERT channel.state == ATTACHED ``` @@ -810,7 +1240,7 @@ root2 = AWAIT channel.object.get() ### Assertions ```pseudo ASSERT root2 IS PathObject -ASSERT root2.path() == "" +ASSERT root2.path == [] ``` --- diff --git a/uts/objects/unit/value_types.md b/uts/objects/unit/value_types.md index dc99aec26..300eb6aae 100644 --- a/uts/objects/unit/value_types.md +++ b/uts/objects/unit/value_types.md @@ -3,11 +3,11 @@ Spec points: `RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4` ## Test Type -Unit test — pure construction and consumption, no mocks required. +Unit test — pure construction and evaluation, no mocks required. ## Purpose -Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When consumed by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). +Tests `LiveCounterValueType` and `LiveMapValueType` — immutable blueprints created via `LiveCounter.create()` and `LiveMap.create()` static factories. When evaluated by a mutation method, they generate `ObjectMessages` with v6 wire format fields (`counterCreateWithObjectId`, `mapCreateWithObjectId`). --- @@ -56,7 +56,7 @@ ASSERT vt.count == 0 **Test ID**: `objects/unit/RTLCV3c/no-validation-at-create-0` -**Spec requirement:** No input validation is performed at creation time; deferred to consumption. +**Spec requirement:** No input validation is performed at creation time. Validation is deferred to the evaluation procedure (RTLCV4). ### Test Steps ```pseudo @@ -70,9 +70,9 @@ ASSERT vt IS LiveCounterValueType --- -## RTLCV4 - Consumption generates COUNTER_CREATE ObjectMessage +## RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage -**Test ID**: `objects/unit/RTLCV4/consume-generates-message-0` +**Test ID**: `objects/unit/RTLCV4/evaluate-generates-message-0` | Spec | Requirement | |------|-------------| @@ -88,7 +88,7 @@ ASSERT vt IS LiveCounterValueType ### Test Steps ```pseudo vt = LiveCounter.create(42) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -106,16 +106,16 @@ ASSERT msg.operation.counterCreateWithObjectId.initialValue IS NOT null --- -## RTLCV4g5 - Consumption retains local CounterCreate +## RTLCV4g5 - Evaluation retains local CounterCreate **Test ID**: `objects/unit/RTLCV4g5/retains-local-counter-create-0` -**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use. +**Spec requirement:** Client must retain CounterCreate alongside CounterCreateWithObjectId for local use (RTLCV4g5). Needed for message size calculation and local application. ### Test Steps ```pseudo vt = LiveCounter.create(42) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -127,16 +127,16 @@ ASSERT msg.operation.counterCreate.count == 42 --- -## RTLCV4a - Consumption validates count type +## RTLCV4a - Evaluation validates count type -**Test ID**: `objects/unit/RTLCV4a/consume-validates-count-0` +**Test ID**: `objects/unit/RTLCV4a/evaluate-validates-count-0` -**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003. +**Spec requirement:** If count is not undefined and (not a Number or not finite), throw 40003 (RTLCV4a). Validation happens during evaluation, not at creation time. ### Test Steps ```pseudo vt = LiveCounter.create("not_a_number") -consume(vt) FAILS WITH error +evaluate(vt) FAILS WITH error ``` ### Assertions @@ -146,16 +146,16 @@ ASSERT error.code == 40003 --- -## RTLCV4 - Consumption with count 0 +## RTLCV4 - Evaluation with count 0 -**Test ID**: `objects/unit/RTLCV4/consume-zero-count-0` +**Test ID**: `objects/unit/RTLCV4/evaluate-zero-count-0` **Spec requirement:** count=0 is valid and should be included in CounterCreate. ### Test Steps ```pseudo vt = LiveCounter.create(0) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -211,9 +211,9 @@ ASSERT vt IS LiveMapValueType --- -## RTLMV4 - Consumption generates MAP_CREATE ObjectMessage +## RTLMV4 - Evaluation generates MAP_CREATE ObjectMessage -**Test ID**: `objects/unit/RTLMV4/consume-generates-message-0` +**Test ID**: `objects/unit/RTLMV4/evaluate-generates-message-0` | Spec | Requirement | |------|-------------| @@ -228,7 +228,7 @@ ASSERT vt IS LiveMapValueType ### Test Steps ```pseudo vt = LiveMap.create({ "name": "Alice" }) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -244,16 +244,16 @@ ASSERT msg.operation.mapCreateWithObjectId.initialValue IS NOT null --- -## RTLMV4j5 - Consumption retains local MapCreate +## RTLMV4j5 - Evaluation retains local MapCreate **Test ID**: `objects/unit/RTLMV4j5/retains-local-map-create-0` -**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use. +**Spec requirement:** Client must retain MapCreate alongside MapCreateWithObjectId for local use (RTLMV4j5). Needed for message size calculation and local application. ### Test Steps ```pseudo vt = LiveMap.create({ "name": "Alice" }) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -287,7 +287,7 @@ vt = LiveMap.create({ "json_arr": [1, 2, 3], "json_obj": { "key": "value" } }) -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -309,8 +309,8 @@ ASSERT entries["json_obj"].data.json == { "key": "value" } | Spec | Requirement | |------|-------------| -| RTLMV4d1 | LiveCounterValueType consumed, ObjectMessage collected, objectId set | -| RTLMV4d2 | LiveMapValueType recursively consumed, all ObjectMessages collected | +| RTLMV4d1 | LiveCounterValueType evaluated, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMapValueType recursively evaluated, all ObjectMessages collected | | RTLMV4k | Return depth-first order: inner creates before outer | ### Test Steps @@ -322,7 +322,7 @@ inner_map = LiveMap.create({ outer = LiveMap.create({ "child": inner_map }) -messages = consume(outer) +messages = evaluate(outer) ``` ### Assertions @@ -345,16 +345,16 @@ ASSERT messages[2].operation.mapCreate.entries["child"].data.objectId == inner_m --- -## RTLMV4a - Consumption validates entries type +## RTLMV4a - Evaluation validates entries type -**Test ID**: `objects/unit/RTLMV4a/consume-validates-entries-0` +**Test ID**: `objects/unit/RTLMV4a/evaluate-validates-entries-0` -**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003. +**Spec requirement:** If entries is not undefined and (is null or not Dict), throw 40003 (RTLMV4a). Validation happens during evaluation, not at creation time. ### Test Steps ```pseudo vt = LiveMap.create(null) -consume(vt) FAILS WITH error +evaluate(vt) FAILS WITH error ``` ### Assertions @@ -364,16 +364,16 @@ ASSERT error.code == 40003 --- -## RTLMV4b - Consumption validates key types +## RTLMV4b - Evaluation validates key types -**Test ID**: `objects/unit/RTLMV4b/consume-validates-keys-0` +**Test ID**: `objects/unit/RTLMV4b/evaluate-validates-keys-0` -**Spec requirement:** If any key is not String, throw 40003. +**Spec requirement:** If any key is not String, throw 40003 (RTLMV4b). ### Test Steps ```pseudo vt = LiveMap.create({ 123: "value" }) -consume(vt) FAILS WITH error +evaluate(vt) FAILS WITH error ``` ### Assertions @@ -383,16 +383,16 @@ ASSERT error.code == 40003 --- -## RTLMV4c - Consumption validates value types +## RTLMV4c - Evaluation validates value types -**Test ID**: `objects/unit/RTLMV4c/consume-validates-values-0` +**Test ID**: `objects/unit/RTLMV4c/evaluate-validates-values-0` -**Spec requirement:** If any value is not an expected type, throw 40013. +**Spec requirement:** If any value is not an expected type, throw 40013 (RTLMV4c). ### Test Steps ```pseudo vt = LiveMap.create({ "fn": some_function }) -consume(vt) FAILS WITH error +evaluate(vt) FAILS WITH error ``` ### Assertions @@ -411,7 +411,7 @@ ASSERT error.code == 40013 ### Test Steps ```pseudo vt = LiveMap.create() -messages = consume(vt) +messages = evaluate(vt) ``` ### Assertions @@ -445,7 +445,7 @@ type_scenarios = [ FOR scenario IN type_scenarios: vt = LiveMap.create({ "test_key": scenario.input }) - messages = consume(vt) + messages = evaluate(vt) entry = messages[0].operation.mapCreate.entries["test_key"] ASSERT entry.data[scenario.expected_field] == scenario.expected_value ```