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/features.md b/specifications/features.md index 51f634d1e..b3c72604f 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`, `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 @@ -1872,6 +1872,13 @@ 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` 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} #### ClientOptions @@ -2260,7 +2267,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 +2847,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 @@ -2926,6 +2933,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 7a15e9cfe..1de79f00a 100644 --- a/specifications/objects-features.md +++ b/specifications/objects-features.md @@ -8,24 +8,29 @@ 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: - - `(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 +### 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 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). + - `(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. @@ -38,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 `RealtimeObjects#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)` `RealtimeObjects#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 `RealtimeObjects#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 @@ -137,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` @@ -148,7 +145,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) @@ -175,15 +172,18 @@ 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) + - `(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 @@ -198,14 +198,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 +244,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 +257,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 +275,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,10 +294,29 @@ 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 +- `(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)` 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 ### LiveObject @@ -314,26 +333,37 @@ 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)` Set to an empty map when the `LiveObject` is initialized - `(RTLO4)` `LiveObject` methods: - - `(RTLO4b)` public `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 + - `(RTLO4b)` `subscribe` - subscribes a user to data updates on this `LiveObject` instance + - `(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 - `(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)` 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 - - `(RTLO4b6)` This operation must not have any side effects on `RealtimeObjects`, 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 + - `(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) + - `(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)` 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` @@ -342,6 +372,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 `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 `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` @@ -350,11 +387,24 @@ 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 `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 + - `(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 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` - `(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 @@ -373,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` @@ -389,25 +439,26 @@ 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 - `(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)` 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 - - `(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` @@ -418,46 +469,49 @@ 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)` 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: - `(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 [RTO12f16](#RTO12f16)): +- `(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: @@ -483,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 @@ -502,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) @@ -523,12 +577,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 - - `(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 + - `(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)` 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 [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. @@ -541,38 +596,45 @@ 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)` 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 - `(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)` 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 - - `(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 [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. - `(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` - `(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)` 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 @@ -581,12 +643,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` @@ -597,46 +659,52 @@ 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)` 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` + - `(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: - `(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 - `(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` @@ -651,18 +719,23 @@ 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` + - `(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: - `(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: - `(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 - `(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` @@ -675,18 +748,22 @@ 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` - `(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 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. - `(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 @@ -699,12 +776,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 [RTO11f18](#RTO11f18)): +- `(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 @@ -717,15 +794,341 @@ 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 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)` `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) + - `(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 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 + - `(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 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)` `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) + - `(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 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`, evaluate it per [RTLCV4](#RTLCV4) to generate a `COUNTER_CREATE` `ObjectMessage`. Collect the generated `ObjectMessage` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the `ObjectMessage` + - `(RTLMV4d2)` If the value is of type `LiveMapValueType`, recursively evaluate it per [RTLMV4](#RTLMV4) to generate an ordered array of `ObjectMessages`. Collect all generated `ObjectMessages` and set `ObjectsMapEntry.data.objectId` to the `objectId` from the final `ObjectMessage` in the array + - `(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 evaluations 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. + +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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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` ([RTPO19e](#RTPO19e)) + - `(RTPO19a2)` `options` `PathObjectSubscriptionOptions` (optional) - subscription options + - `(RTPO19b)` Checks the access API preconditions per [RTO25](#RTO25) + - `(RTPO19c)` `PathObjectSubscriptionOptions` has the following properties: + - `(RTPO19c1)` `depth` `Number` (optional) - controls how many levels deep in the subtree changes trigger the listener. Defaults to undefined/null. The `depth` value is interpreted by the subscription coverage rule in [RTO24c1](#RTO24c1); see [RTO24c2](#RTO24c2) for worked examples + - `(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 + +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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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)` 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` ([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 [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` + - `(PAOM2c)` `connectionId` string (optional) - the `connectionId` ([OM2c](../features#OM2c)) 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` + - `(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)` 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 + +- `(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 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 - createMap(Dict entries?) => io LiveMap // RTO11 - createCounter(Number count?) => io LiveCounter // RTO12 + class RealtimeObject: // RTO* + get() => io PathObject // RTO23 on(ObjectsEvent event, (() ->) callback) -> StatusSubscription // RTO18 off(() ->) // RTO19 publish(ObjectMessage[]) => io PublishResult // RTO15, internal @@ -747,41 +1150,119 @@ 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 - subscribe((LiveObjectUpdate) ->) -> LiveObjectSubscription // RTLO4b - unsubscribe((LiveObjectUpdate) ->) // RTLO4c - - interface LiveObjectSubscription: // RTLO4b5 - unsubscribe() // RTLO4b5a - - interface LiveObjectUpdate: // RTLO4b4 + class LiveObject: // RTLO*, internal + objectId: String // RTLO3a + siteTimeserials: Dict // RTLO3b + createOperationIsMerged: Boolean // RTLO3c + isTombstone: Boolean // RTLO3d + tombstonedAt: Time? // RTLO3e + 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 + + interface LiveObjectUpdate: // RTLO4b4, internal update: Object // RTLO4b4a - noop: Boolean // RTLO4b4b, internal + noop: Boolean // RTLO4b4b + objectMessage: ObjectMessage? // RTLO4b4d + tombstone: Boolean // RTLO4b4e - class LiveCounter extends LiveObject: // RTLC*, RTLC1 + 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 + interface LiveCounterUpdate extends LiveObjectUpdate: // RTLC11, RTLC11a, internal update: { amount: Number } // RTLC11b, RTLC11b1 - class LiveMap extends LiveObject: // RTLM*, RTLM1 - clearTimeserial: String? // RTLM25, internal + class LiveMap extends LiveObject: // RTLM*, RTLM1, internal + 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 | LiveCounter | LiveMap) value) => io // RTLM20 + 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 + interface LiveMapUpdate extends LiveObjectUpdate: // RTLM18, RTLM18a, internal 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: // RTPO19e + object: PathObject // RTPO19e1 + message: PublicAPI::ObjectMessage? // RTPO19e2 + + interface PathObjectSubscriptionOptions: // RTPO19c + depth: Number? // RTPO19c1 + + interface InstanceSubscriptionEvent: // RTINS16e + object: Instance // RTINS16e1 + message: PublicAPI::ObjectMessage? // RTINS16e2 + + 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 + get(String key) -> PathObject // RTPO5 + at(String path) -> PathObject // RTPO6 + value() -> (Boolean | Binary | Number | String | JsonArray | JsonObject)? // RTPO7 + instance() -> Instance? // RTPO8 + entries() -> [String, PathObject][] // RTPO9 + keys() -> String[] // RTPO10 + values() -> PathObject[] // RTPO11 + size() -> Number? // RTPO12 + compact() -> Object? // RTPO13 + compactJson() -> Object? // RTPO14 + 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 + subscribe((PathObjectSubscriptionEvent) -> listener, PathObjectSubscriptionOptions? options) -> Subscription // RTPO19 + + class Instance: // RTINS* + id: String? // RTINS3 + value() -> (Boolean | Binary | Number | String | JsonArray | JsonObject)? // RTINS4 + get(String key) -> Instance? // RTINS5 + entries() -> [String, Instance][] // RTINS6 + keys() -> String[] // RTINS7 + values() -> Instance[] // RTINS8 + size() -> Number? // RTINS9 + compact() -> Object? // RTINS10 + compactJson() -> Object? // RTINS11 + 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 + subscribe((InstanceSubscriptionEvent) -> listener) -> Subscription // RTINS16 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/PLAN.md b/uts/objects/PLAN.md new file mode 100644 index 000000000..a95734829 --- /dev/null +++ b/uts/objects/PLAN.md @@ -0,0 +1,385 @@ +# 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` — 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. + +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), 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}` + +**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-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 (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-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, 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`~~ | ~~Batch API not in current spec revision~~ | — | +| `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: ~20 files, ~310 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 evaluation procedure. + +**LiveCounterValueType (RTLCV1-4):** +1. `LiveCounter.create(42)` -> immutable LiveCounterValueType with count=42 +2. `LiveCounter.create()` -> count defaults to 0 +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. 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) + +--- + +## 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 (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 + +- **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 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, 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/parent_references.md` -- parentReferences Tracking + +- **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 + +--- + +## 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/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` + +--- + +## 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 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 | +| 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..093b1e996 --- /dev/null +++ b/uts/objects/helpers/standard_test_pool.md @@ -0,0 +1,367 @@ +# 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. + +### 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 + +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) +``` + +### 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( + 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..8988a0191 --- /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", + + 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", + + 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", + + 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", + + 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", + + 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/instance.md b/uts/objects/unit/instance.md new file mode 100644 index 000000000..bf8a67b47 --- /dev/null +++ b/uts/objects/unit/instance.md @@ -0,0 +1,582 @@ +# Instance Tests + +Spec points: `RTINS1`–`RTINS16` + +## 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 | Checks access API preconditions per RTO25 | +| RTINS4b | LiveCounter -> delegates to LiveCounter#value | +| RTINS4c | Primitive -> returns value directly | +| RTINS4d | 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 | Checks access API preconditions per RTO25 | +| RTINS5c | LiveMap -> look up key, wrap result in Instance | +| RTINS5d | 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() returns array of [key, Instance] pairs + +**Test ID**: `objects/unit/RTINS6/entries-yields-instances-0` + +| Spec | Requirement | +|------|-------------| +| RTINS6a | Checks access API preconditions per RTO25 | +| RTINS6b | LiveMap -> array of [key, Instance] pairs | +| RTINS6c | Non-LiveMap -> empty array | + +### 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 | Checks access API preconditions per RTO25 | +| RTINS9b | LiveMap -> non-tombstoned entry count | +| RTINS9c | 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 | +|------|-------------| +| RTINS10a | Checks access API preconditions per RTO25 | +| RTINS10b | 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 | Checks write API preconditions per RTO26 | +| RTINS12c | LiveMap -> delegate to LiveMap#set | +| RTINS12d | 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" +``` + +--- + +## RTINS12d - set() on non-LiveMap throws 92007 + +**Test ID**: `objects/unit/RTINS12d/set-non-map-throws-0` + +**Spec requirement:** If the wrapped value is not a LiveMap, throw ErrorInfo with code 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.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` + +| 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") +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 | Checks write API preconditions per RTO26 | +| RTINS14c | LiveCounter -> delegate to increment | +| RTINS14d | 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 +``` + +--- + +## RTINS14d - increment() on non-LiveCounter throws 92007 + +**Test ID**: `objects/unit/RTINS14d/increment-non-counter-throws-0` + +**Spec requirement:** If the wrapped value is not a LiveCounter, throw ErrorInfo with code 92007. + +### 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` + +| 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") +counter_inst = root.get("score").instance() +``` + +### Test Steps +```pseudo +AWAIT counter_inst.decrement(10) +``` + +### Assertions +```pseudo +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 | +|------|-------------| +| 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 +{ 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" +``` + +--- + +## RTINS16c - subscribe() on primitive throws 92007 + +**Test ID**: `objects/unit/RTINS16c/subscribe-primitive-throws-0` + +**Spec requirement:** If wrapped value is not a LiveObject (i.e. it is a primitive), throw ErrorInfo with code 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 +``` + +--- + +## RTINS16e2 - InstanceSubscriptionEvent contains PublicAPI::ObjectMessage + +**Test ID**: `objects/unit/RTINS16e2/subscription-event-message-0` + +| Spec | Requirement | +|------|-------------| +| RTINS16e1 | Event.object is an Instance wrapping the LiveObject | +| RTINS16e2 | Event.message is a PublicAPI::ObjectMessage derived from the triggering ObjectMessage | + +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") +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.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" +``` + +--- + +## RTINS16f - subscribe() returns Subscription for deregistration + +**Test ID**: `objects/unit/RTINS16f/subscribe-returns-subscription-0` + +**Spec requirement:** Returns a Subscription object (RTINS16f). Deregistration is via Subscription#unsubscribe. + +### 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 +``` + +--- + +## RTINS16g - Instance subscription follows identity not path + +**Test ID**: `objects/unit/RTINS16g/subscription-follows-identity-0` + +**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 +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" +``` + +--- + +## RTINS16h - subscribe() has no side effects + +**Test ID**: `objects/unit/RTINS16h/subscribe-no-side-effects-0` + +**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") +counter_inst = root.get("score").instance() +channel_state_before = channel.state +``` + +### Test Steps +```pseudo +sub = counter_inst.subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == channel_state_before +``` diff --git a/uts/objects/unit/live_counter.md b/uts/objects/unit/live_counter.md new file mode 100644 index 000000000..d5f2c3401 --- /dev/null +++ b/uts/objects/unit/live_counter.md @@ -0,0 +1,841 @@ +# LiveCounter Tests + +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. + +## 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 and objectMessage set to the provided ObjectMessage | + +### 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 +ASSERT update.objectMessage == msg +``` + +--- + +## 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 +ASSERT update.objectMessage == msg +``` + +--- + +## 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 and objectMessage set to the provided ObjectMessage | + +### 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 +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +| RTLO5c | Return the LiveObjectUpdate returned by tombstone | +| RTLO4e2 | Set isTombstone to true | +| RTLO4e4 | Set data to zero-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 +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 +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg +``` + +--- + +## 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 with objectMessage set to the provided ObjectMessage | + +### 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 +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 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 +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 +ASSERT update.tombstone == true +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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 +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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..f6bca2a1a --- /dev/null +++ b/uts/objects/unit/live_counter_api.md @@ -0,0 +1,259 @@ +# 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 | + +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") +``` + +### Assertions +```pseudo +counter = root.get("score") +ASSERT counter.value() == 100 +``` + +--- + +## 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/c/d - increment write preconditions (replaced by RTO26) + +**Test ID**: `objects/unit/RTLC12b/increment-requires-publish-0` + +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`. + +--- + +## 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..0186570bb --- /dev/null +++ b/uts/objects/unit/live_map.md @@ -0,0 +1,1378 @@ +# LiveMap Tests + +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, diff calculation, and parentReferences maintenance. + +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" and objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +| RTLM7f | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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" and objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +| RTLM8e | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 and objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +| RTLM23c | Return LiveMapUpdate with merged update map and objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 | +|------|-------------| +| 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 +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" } +ASSERT update.tombstone == true +ASSERT update.objectMessage == msg +``` + +--- + +## 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 with objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == state_msg +``` + +--- + +## 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, passing in the ObjectMessage. + +### 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 +``` + +--- + +## 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` + +**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 | +| RTLM7f | Return LiveMapUpdate with objectMessage set to the provided ObjectMessage | + +### 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" } +ASSERT update.objectMessage == msg +``` + +--- + +## 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 +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 new file mode 100644 index 000000000..44dcb795b --- /dev/null +++ b/uts/objects/unit/live_map_api.md @@ -0,0 +1,528 @@ +# LiveMap API Tests + +Spec points: `RTLM5`, `RTLM10`–`RTLM13`, `RTLM20`–`RTLM21`, `RTLM24`, `RTLMV4`, `RTLCV4` + +## 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 | +|------|-------------| +| 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 +{ 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 | +|------|-------------| +| 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 +{ 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 | +|------|-------------| +| 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 +{ 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 | +|------|-------------| +| 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 +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 generates COUNTER_CREATE + MAP_SET + +**Test ID**: `objects/unit/RTLM20e7g/set-counter-value-type-0` + +| Spec | Requirement | +|------|-------------| +| 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 +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 +``` + +--- + +## RTLM20e7g - set() with LiveMapValueType generates nested CREATE messages + MAP_SET + +**Test ID**: `objects/unit/RTLM20e7g/set-map-value-type-0` + +| Spec | Requirement | +|------|-------------| +| 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 RTLM20 set-sends-map-set-0, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.set("nested_map", LiveMap.create({ "key1": "value1" })) +``` + +### Assertions +```pseudo +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 +``` + +--- + +## RTLM20h1 - set() with nested LiveMapValueType containing LiveCounterValueType + +**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 | + +Tests that when a LiveMapValueType contains a nested LiveCounterValueType, all CREATE messages appear before the MAP_SET in depth-first order. + +### Setup +```pseudo +captured_messages = [] +// (same mock setup as RTLM20 set-sends-map-set-0, capturing OBJECT messages) +``` + +### Test Steps +```pseudo +AWAIT root.set("stats", LiveMap.create({ + "count": LiveCounter.create(0), + "label": "test" +})) +``` + +### Assertions +```pseudo +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 +``` + +--- + +## RTLM21 - remove() sends MAP_REMOVE message + +**Test ID**: `objects/unit/RTLM21/remove-sends-map-remove-0` + +| 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 +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/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` + +**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 | +|------|-------------| +| RTLM20e1 | Validates value per RTLMV4c | +| RTLMV4c | Unsupported value types throw 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..7911e852e --- /dev/null +++ b/uts/objects/unit/live_object_subscribe.md @@ -0,0 +1,386 @@ +# LiveObject Subscribe Tests + +Spec points: `RTLO4b`, `RTLO4b3`, `RTLO4b4c1`, `RTLO4b4c3a`, `RTLO4b4c3c`, `RTLO4b4d`, `RTLO4b4e`, `RTLO4b6`, `RTLO4b7` + +## 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 | +| RTLO4b4c3a | Registered listeners 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 +``` + +--- + +## RTLO4b7 - subscribe returns Subscription with unsubscribe method + +**Test ID**: `objects/unit/RTLO4b7/subscribe-returns-subscription-0` + +| 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") +instance = root.get("score").instance() +``` + +### Test Steps +```pseudo +sub = instance.subscribe((event) => {}) +``` + +### Assertions +```pseudo +ASSERT sub IS Subscription +ASSERT sub.unsubscribe IS Function +``` + +--- + +## RTLO4b7 - Subscription#unsubscribe stops delivery + +**Test ID**: `objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0` + +| Spec | Requirement | +|------|-------------| +| 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 +{ 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 +``` + +--- + +## RTLO4b7 - Subscription#unsubscribe is idempotent + +**Test ID**: `objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0` + +**Spec requirement:** Calling `Subscription#unsubscribe()` multiple times must not throw or produce errors. + +### 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() +sub.unsubscribe() +``` + +### Assertions +```pseudo +// 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 +``` + +--- + +## 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 +``` + +--- + +## RTLO4b4c3c - tombstone update deregisters all LiveObject#subscribe listeners + +**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 | + +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() +instance.subscribe((event) => updates_a.append(event)) +instance.subscribe((event) => updates_b.append(event)) +``` + +### Test Steps +```pseudo +# 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 +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/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..cbbf400ed --- /dev/null +++ b/uts/objects/unit/objects_pool.md @@ -0,0 +1,1133 @@ +# 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, without populating objectMessage | +| 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" } +ASSERT updates[0].objectMessage IS null +``` + +--- + +## 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" } +``` + +--- + +## 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 new file mode 100644 index 000000000..96d989754 --- /dev/null +++ b/uts/objects/unit/path_object.md @@ -0,0 +1,765 @@ +# 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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7c | LiveCounter -> delegates to LiveCounter#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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7d | Primitive -> returns 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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7e | 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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7f | Resolution failure -> returns null per RTPO3c1 | + +### 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 | +|------|-------------| +| RTPO8a | Checks access API preconditions per RTO25 | +| RTPO8c | LiveObject -> 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 | +|------|-------------| +| RTPO8a | Checks access API preconditions per RTO25 | +| RTPO8d | 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() returns array of [key, PathObject] pairs + +**Test ID**: `objects/unit/RTPO9/entries-yields-pairs-0` + +| Spec | Requirement | +|------|-------------| +| 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 +{ 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 array for non-LiveMap + +**Test ID**: `objects/unit/RTPO9d/entries-non-map-empty-0` + +| Spec | Requirement | +|------|-------------| +| RTPO9a | Checks access API preconditions per RTO25 | +| RTPO9d | Not LiveMap or resolution failure -> returns empty array | + +### Setup +```pseudo +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +``` + +### Test Steps +```pseudo +entries = root.get("score").entries() +``` + +### Assertions +```pseudo +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 | +|------|-------------| +| RTPO12a | Checks access API preconditions per RTO25 | +| RTPO12c | LiveMap -> delegates to LiveMap#size (RTLM10) | + +### 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` + +| 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") +``` + +### 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 | +|------|-------------| +| 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 +{ 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 | +|------|-------------| +| RTPO13a | Checks access API preconditions per RTO25 | +| RTPO13c5 | Cyclic references reuse 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` + +| 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") +``` + +### 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 | +|------|-------------| +| RTPO14a | Checks access API preconditions per RTO25 | +| RTPO14b1 | Binary as base64 strings | +| RTPO14b2 | 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 | +|------|-------------| +| RTPO7a | Checks access API preconditions per RTO25 | +| RTPO7d | Primitive (Binary) -> returns 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 | +|------|-------------| +| RTPO14a | Checks access API preconditions per RTO25 | +| RTPO14b1 | Binary values encoded as base64 strings | + +### 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..43e8f2d59 --- /dev/null +++ b/uts/objects/unit/path_object_mutations.md @@ -0,0 +1,358 @@ +# 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 | Checks write API preconditions per RTO26 | +| RTPO15c | Resolves path, on failure throws RTPO3c2 | +| RTPO15d | LiveMap -> delegates to LiveMap#set (RTLM20) | + +### 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` + +| 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") +``` + +### 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 | +|------|-------------| +| RTPO15b | Checks write API preconditions per RTO26 | +| RTPO15e | Not LiveMap -> throws 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 | Checks write API preconditions per RTO26 | +| RTPO16c | Resolves path, on failure throws RTPO3c2 | +| RTPO16d | LiveMap -> delegates to LiveMap#remove (RTLM21) | + +### 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 | +|------|-------------| +| RTPO16b | Checks write API preconditions per RTO26 | +| RTPO16e | Not LiveMap -> throws 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 | Checks write API preconditions per RTO26 | +| RTPO17c | Resolves path, on failure throws RTPO3c2 | +| RTPO17d | LiveCounter -> delegates to LiveCounter#increment (RTLC12) | + +### 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 | +|------|-------------| +| RTPO17a1 | amount defaults to 1 | +| RTPO17b | Checks write API preconditions per RTO26 | + +### 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 | +|------|-------------| +| RTPO17b | Checks write API preconditions per RTO26 | +| RTPO17e | Not LiveCounter -> throws 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 | Checks write API preconditions per RTO26 | +| RTPO18c | Resolves path, on failure throws RTPO3c2 | +| RTPO18d | LiveCounter -> delegates to LiveCounter#decrement (RTLC13) | + +### 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 | +|------|-------------| +| RTPO18a1 | amount defaults to 1 | +| RTPO18b | Checks write API preconditions per RTO26 | + +### 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 | +|------|-------------| +| RTPO18b | Checks write API preconditions per RTO26 | +| RTPO18e | Not LiveCounter -> throws 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 | +|------|-------------| +| RTPO15b | 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") +``` + +### Test Steps +```pseudo +AWAIT root.get("nonexistent").get("deep").set("key", "value") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 92005 +ASSERT error.statusCode == 400 +``` + +--- + +## RTPO3c2 - increment() on unresolvable path throws 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") +``` + +### Test Steps +```pseudo +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 new file mode 100644 index 000000000..5f0de3fbc --- /dev/null +++ b/uts/objects/unit/path_object_subscribe.md @@ -0,0 +1,798 @@ +# PathObject Subscribe Tests + +Spec points: `RTPO19`, `RTO24`, `RTO25` + +## 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 | +|------|-------------| +| RTPO19d | Returns Subscription object | +| RTPO19e1 | Event.object is a PathObject pointing to change path | +| RTPO19e2 | Event.message is the PublicAPI::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 +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" +``` + +--- + +## RTPO19b - subscribe() checks RTO25 access API preconditions on DETACHED channel + +**Test ID**: `objects/unit/RTPO19b/subscribe-precondition-detached-0` + +| 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 +{ 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 +``` + +--- + +## RTPO19c1 - subscribe() with depth 2 receives self and children + +**Test ID**: `objects/unit/RTPO19c1/subscribe-depth-2-children-0` + +**Spec requirement:** depth=2 means changes at the subscribed path and one level of children trigger the listener (RTO24c2c). + +### 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 +``` + +--- + +## RTPO19c1 - subscribe() with no depth receives all descendants + +**Test ID**: `objects/unit/RTPO19c1/subscribe-unlimited-depth-0` + +**Spec requirement:** If depth is undefined, subscription receives events at any depth (RTO24c2a). + +### 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 +``` + +--- + +## RTPO19d - subscribe() returns Subscription with unsubscribe() + +**Test ID**: `objects/unit/RTPO19d/subscribe-returns-subscription-0` + +**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 +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 events.length == 0 +``` + +--- + +## RTPO19e1 - subscribe() event provides correct PathObject + +**Test ID**: `objects/unit/RTPO19e1/event-path-object-correct-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 +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 +``` + +--- + +## RTPO19e2 - subscribe() event delivers PublicAPI::ObjectMessage for operations + +**Test ID**: `objects/unit/RTPO19e2/event-message-delivery-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 | + +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 +{ client, channel, root, mock_ws } = AWAIT setup_synced_channel("test") +events = [] +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", 42, "serial-1", "site-a") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +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 +``` + +--- + +## RTPO19e2 - subscribe() event omits message when objectMessage has no operation + +**Test ID**: `objects/unit/RTPO19e2/event-message-omitted-no-operation-0` + +**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.subscribe((event) => events.append(event)) +``` + +### Test Steps +```pseudo +// 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) +``` + +### Assertions +```pseudo +// Events from sync-triggered updates should have no message +FOR event IN events: + ASSERT event.message IS null OR event.message IS undefined +``` + +--- + +## RTPO19f - subscribe() follows path not identity + +**Test ID**: `objects/unit/RTPO19f/subscribe-follows-path-0` + +**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 = [] +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 +``` + +--- + +## 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 - 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" +``` + +--- + +## 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 - child events bubble up to parent subscription + +**Test ID**: `objects/unit/RTPO19/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 +``` + +--- + +## RTO24c1 - depth filtering formula + +**Test ID**: `objects/unit/RTO24c1/depth-filtering-formula-0` + +| 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 = [] +// 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("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 +``` + +--- + +## RTO24c1 - prefix mismatch does not trigger subscription + +**Test ID**: `objects/unit/RTO24c1/prefix-mismatch-0` + +| 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") +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") +])) + +// 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 profile_events.length == 0 +``` + +--- + +## RTO24b2a - candidate path construction includes map update keys + +**Test ID**: `objects/unit/RTO24b2a/candidate-paths-map-keys-0` + +| Spec | Requirement | +|------|-------------| +| 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") +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_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) +poll_until(score_events.length >= 1, timeout: 5s) +poll_until(root_events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT score_events.length == 1 +ASSERT score_events[0].object.path() == "score" +ASSERT root_events.length == 1 +``` + +--- + +## RTO24b2c - listener exception does not affect other listeners + +**Test ID**: `objects/unit/RTO24b2c/listener-exception-caught-0` + +**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") +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 +``` + +--- + +## RTO24b1 - dispatch via getFullPaths for multi-path objects + +**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 | + +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") +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_map_set("root", "alias", { objectId: "counter:score@1000" }, "98", "remote") +])) + +root.get("score").subscribe((event) => events_score.append(event)) +root.get("alias").subscribe((event) => events_alias.append(event)) +``` + +### 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", 5, "99", "remote") +])) +poll_until(events_score.length >= 1, timeout: 5s) +poll_until(events_alias.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +ASSERT events_score.length == 1 +ASSERT events_score[0].object.path() == "score" +ASSERT events_alias.length == 1 +ASSERT events_alias[0].object.path() == "alias" +``` + +--- + +## RTO24b2b - subscription fires exactly once per dispatch + +**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 | + +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") +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_map_set("root", "score", { objectId: "counter:new@2000" }, "99", "remote") +])) +poll_until(events.length >= 1, timeout: 5s) +``` + +### Assertions +```pseudo +// 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 new file mode 100644 index 000000000..4a0fc116e --- /dev/null +++ b/uts/objects/unit/realtime_object.md @@ -0,0 +1,1357 @@ +# RealtimeObject Tests + +Spec points: `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO26` + +## 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 with path set to empty list and root set to root LiveMap | + +### 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 channel + +**Test ID**: `objects/unit/RTO23b/get-throws-detached-0` + +| 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 +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 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 +``` + +--- + +## 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 +ASSERT root.path == [] +``` + +--- + +## 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 +``` + +--- + +## 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` + +| 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 root.path == [] +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..300eb6aae --- /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 evaluation, no mocks required. + +## Purpose + +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`). + +--- + +## 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. Validation is deferred to the evaluation procedure (RTLCV4). + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +``` + +### Assertions +```pseudo +ASSERT vt IS LiveCounterValueType +``` + +--- + +## RTLCV4 - Evaluation generates COUNTER_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLCV4/evaluate-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 = evaluate(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 - 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 (RTLCV4g5). Needed for message size calculation and local application. + +### Test Steps +```pseudo +vt = LiveCounter.create(42) +messages = evaluate(vt) +``` + +### Assertions +```pseudo +msg = messages[0] +ASSERT msg.operation.counterCreate IS NOT null +ASSERT msg.operation.counterCreate.count == 42 +``` + +--- + +## RTLCV4a - Evaluation validates count type + +**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 (RTLCV4a). Validation happens during evaluation, not at creation time. + +### Test Steps +```pseudo +vt = LiveCounter.create("not_a_number") +evaluate(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLCV4 - Evaluation with 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 = evaluate(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 - Evaluation generates MAP_CREATE ObjectMessage + +**Test ID**: `objects/unit/RTLMV4/evaluate-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 = evaluate(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 - 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 (RTLMV4j5). Needed for message size calculation and local application. + +### Test Steps +```pseudo +vt = LiveMap.create({ "name": "Alice" }) +messages = evaluate(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 = evaluate(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 evaluated, ObjectMessage collected, objectId set | +| RTLMV4d2 | LiveMapValueType recursively evaluated, 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 = evaluate(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 - Evaluation validates entries type + +**Test ID**: `objects/unit/RTLMV4a/evaluate-validates-entries-0` + +**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) +evaluate(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4b - Evaluation validates key types + +**Test ID**: `objects/unit/RTLMV4b/evaluate-validates-keys-0` + +**Spec requirement:** If any key is not String, throw 40003 (RTLMV4b). + +### Test Steps +```pseudo +vt = LiveMap.create({ 123: "value" }) +evaluate(vt) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40003 +``` + +--- + +## RTLMV4c - Evaluation validates value types + +**Test ID**: `objects/unit/RTLMV4c/evaluate-validates-values-0` + +**Spec requirement:** If any value is not an expected type, throw 40013 (RTLMV4c). + +### Test Steps +```pseudo +vt = LiveMap.create({ "fn": some_function }) +evaluate(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 = evaluate(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 = evaluate(vt) + entry = messages[0].operation.mapCreate.entries["test_key"] + ASSERT entry.data[scenario.expected_field] == scenario.expected_value +``` 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": {